Tick Processor
Physics Model
The game uses real-world physics. Exceptions are documented explicitly.
Principles
- Newtonian mechanics for ship movement
- Conservation of momentum and energy
- Speed of light acknowledged but not enforced (see below)
Speed of Light (MVP)
Speed of light is not enforced in MVP because it’s physically unreachable:
| Parameter | Value |
|---|---|
| Maximum ship delta-v | ~180 km/s |
| Speed of light | 299,792 km/s |
| Maximum velocity ratio | 0.0006c |
Relativistic effects are negligible at these velocities and not simulated. Ships use pure Newtonian mechanics.
Future releases with faster propulsion may add:
- Velocity clamping at c
- Relativistic mass increase
- Time dilation effects
Gravity Model
- N-body simulation for celestial bodies above mass threshold (asteroids, moons, planets, stars)
- Mass threshold: 10^12 kg
- Ships: Affected by gravity but do not exert gravitational influence on other bodies
- Gravitational constant: G = 6.67430 × 10^-11 m³/(kg·s²)
- Distance guard: When the distance between two bodies (or a ship and a body) is < 10^-10 m, the gravitational interaction is skipped and a warning is logged. This prevents division-by-zero singularities and flags potential position bugs.
All moons in the initial release are above the mass threshold:
| Body | Mass (kg) | In N-body |
|---|---|---|
| Deimos (smallest) | 1.48 × 10^15 | Yes |
| Phobos | 1.07 × 10^16 | Yes |
| All other moons | > 10^19 | Yes |
Propulsion
Ships and objects may have multiple propulsion devices. Types include:
- Chemical rockets: High thrust, low efficiency
- Ion drives: Low thrust, high efficiency
- Fusion drives: Medium-high thrust, high efficiency
Each propulsion device specifies:
| Property | Unit | Description |
|---|---|---|
| thrust | N | Force produced |
| specific_impulse | s | Fuel efficiency (Isp) |
| fuel_rate | kg/s | Fuel consumption at full thrust |
| mass | kg | Dry mass of the drive |
Fuel
- Ships must manage fuel reserves
- Fuel has mass and affects ship performance
- Delta-v calculated via Tsiolkovsky rocket equation: Δv = Isp × g₀ × ln(m₀/m₁)
- g₀ = 9.80665 m/s² (standard gravity)
- m₀ = initial mass (with fuel)
- m₁ = final mass (without fuel)
Ships
- Ships are treated as point masses (no physical extent for collision)
- No collision detection (initial release)
See also: Ships for detailed ship specifications (mass, thrust, inertia tensor, fuel capacity)
Ship Properties
| Property | Type | Description |
|---|---|---|
| position | vec3 | Location in space (meters) |
| velocity | vec3 | Linear velocity (m/s) |
| attitude | quaternion | Orientation in space |
| angular_velocity | vec3 | Rotational velocity (rad/s) |
| mass | float | Total mass including fuel (kg) |
| inertia_tensor | mat3 | Rotational inertia (kg·m²) |
Ship-Local Coordinate System
| Axis | Direction | Description |
|---|---|---|
| +X | Right | Starboard |
| +Y | Up | Dorsal |
| +Z | Forward | Nose, thrust direction |
Thrust is applied along the +Z axis in ship-local coordinates, transformed to world coordinates via the attitude quaternion.
This follows standard 3D graphics conventions (Y-up, Z-forward) and aligns with Three.js defaults used by the web client.
Player Controls
| Control | Type | Description |
|---|---|---|
| rotation | vec3 | Pitch/yaw/roll input (-1 to 1 per axis) |
| thrust_level | float | Engine thrust (0.0 to 1.0) |
| translation_input | vec3 | RCS translation input (-1 to 1 per axis, body frame) |
Attitude Control (MVP)
- Player directly controls rotation via pitch/yaw/roll inputs
- Inputs apply torque to the ship (no automatic stabilization)
- Angular acceleration: α = I⁻¹ × τ (inverse inertia tensor × torque)
- Player must manually counter-rotate to stop spinning
Torque Application Formula
Player rotation input [pitch, yaw, roll] maps to torque around body axes:
| Input Index | Name | Body Axis | Torque Direction |
|---|---|---|---|
| 0 | Pitch | X | Positive = nose up |
| 1 | Yaw | Y | Positive = nose left |
| 2 | Roll | Z | Positive = roll left |
Player input maps linearly to requested torque:
requested_torque[X] = max_wheel_torque × rotation_input[0] # pitch
requested_torque[Y] = max_wheel_torque × rotation_input[1] # yaw
requested_torque[Z] = max_wheel_torque × rotation_input[2] # roll
where max_wheel_torque = 1,000 N·m
Per-axis torque resolution each tick:
if abs(wheel_momentum[axis]) < wheel_capacity:
# Wheels can provide torque
available_wheel_torque = min(requested_torque, max_wheel_torque)
applied_torque = available_wheel_torque
wheel_momentum[axis] += applied_torque × dt
else:
# Wheels saturated, RCS supplements
wheel_contribution = 0 (saturated direction)
rcs_contribution = min(requested_torque, max_rcs_torque)
applied_torque = rcs_contribution
# RCS fuel consumed (see Ships spec)
When wheels are partially saturated (can provide some but not all):
wheel_headroom = wheel_capacity - abs(wheel_momentum[axis])
wheel_contribution = min(requested_torque × dt, wheel_headroom) / dt
rcs_contribution = requested_torque - wheel_contribution
applied_torque = wheel_contribution + rcs_contribution
Torque sources:
| System | Fuel Use | Characteristics |
|---|---|---|
| Reaction wheels | None | Fine control, can saturate (store angular momentum) |
| RCS thrusters | Yes | Higher torque, desaturates reaction wheels |
Reaction Wheel Behavior
- Wheels store angular momentum when applying torque
- Each axis has independent momentum storage (capacity: 10,000 N·m·s per axis)
- When momentum reaches capacity on an axis, wheels cannot apply torque in that direction
Wheel Saturation Calculation
Wheel saturation is computed per-axis for the WebSocket state message:
wheel_saturation[axis] = abs(wheel_momentum[axis]) / wheel_capacity
where:
wheel_momentum[axis] = current stored angular momentum (N·m·s)
wheel_capacity = 10,000 N·m·s (per axis)
| wheel_momentum | wheel_saturation | Meaning |
|---|---|---|
| 0 N·m·s | 0.0 | Wheels empty, full capacity available |
| 5,000 N·m·s | 0.5 | 50% saturated, triggers auto-desaturation |
| 10,000 N·m·s | 1.0 | Fully saturated, RCS required for torque |
| -10,000 N·m·s | 1.0 | Fully saturated (opposite direction) |
Note: Saturation is always positive (uses absolute value) since saturation represents capacity used regardless of momentum direction.
RCS Engagement (Automatic)
| Condition | RCS Action |
|---|---|
| Player input requires torque, wheels saturated | RCS provides torque (uses fuel) |
| Player manual input is zero, wheels >50% saturated | RCS desaturates wheels (uses fuel) |
| Player input requires more torque than wheels can provide | RCS supplements wheels |
Priority
- Reaction wheels apply torque first (no fuel cost)
- RCS supplements when wheels insufficient or saturated
- RCS desaturates wheels when player is not manually controlling
Rate Damping Controller (Attitude Hold)
When attitude hold is enabled, the physics service automatically computes rotation input to zero out angular velocity. This is rate damping, not true attitude hold—the ship stops rotating but does not maintain a specific orientation.
Controller gain:
| Gain | Value | Purpose |
|---|---|---|
| Kd | 8.0 | Derivative gain for angular velocity damping |
Control law:
control[axis] = -Kd × angular_velocity[axis]
control[axis] = clamp(control[axis], -1, 1)
The controller applies torque proportional to (and opposite of) angular velocity. Once the ship stops rotating (angular velocity ≈ 0), no torque is applied.
Torque scaling for rate damping:
When attitude hold is active and wheels are saturated on an axis, the control signal is scaled by max_rcs_torque (10,000 N·m) instead of max_wheel_torque (1,000 N·m). This allows full RCS authority for rapid rate damping:
if attitude_hold AND wheel_saturation[axis] >= 1.0:
requested_torque[axis] = control[axis] × max_rcs_torque
else:
requested_torque[axis] = control[axis] × max_wheel_torque
Activation behavior:
When attitude hold is enabled:
- Pending rotation_input is cleared (zeroed)
- Controller immediately begins damping angular rates
Ship Tick Processing Order
For each ship, physics processes the following steps in order each tick:
| Step | Operation | Fuel Consumed |
|---|---|---|
| 1 | Read player rotation_input, translation_input, and thrust_level from Redis | — |
| 2 | Calculate required torque from rotation_input | — |
| 3 | Apply torque via reaction wheels (update wheel_momentum) | None |
| 4 | If wheels saturated: apply remaining torque via RCS | RCS fuel |
| 5 | If player manual input is zero AND wheel saturation > 50%: RCS desaturation | RCS fuel |
| 6 | Calculate angular acceleration from applied torque | — |
| 7 | Integrate angular velocity (Euler method) | — |
| 8 | Integrate attitude quaternion | — |
| 8a | Compute RCS translation force (body frame → ICRF via attitude quaternion) | RCS trans fuel |
| 9 | Calculate main engine thrust force from thrust_level | — |
| 10 | Consume total fuel (main engine + rotational RCS + translation RCS) | All fuel |
| 11 | Apply thrust + RCS translation force to velocity (half-step for Leapfrog) | — |
| 12 | Apply gravitational acceleration (N-body) | — |
| 13 | Update position (Leapfrog) | — |
| 14 | Complete velocity update (Leapfrog second half-step) | — |
| 15 | Write updated state to Redis | — |
Key ordering notes:
- Steps 4 and 5 can run concurrently during automatic attitude control (controller torque and desaturation on different axes); they are only mutually exclusive per axis for player manual input
- Main engine fuel (step 10) is consumed before thrust is applied, so partial fuel results in proportional thrust
- Step 12 (initial gravity) uses pre-update body positions (time T), consistent with ship position at time T
- Step 14 (second gravity) uses post-update body positions (time T+dt), consistent with ship position at time T+dt. This Strang splitting ensures time-symmetry for the combined body-ship system.
- Attitude reference body lookup (used in steps 1-8 for prograde/retrograde) uses pre-update body positions for Hill sphere consistency
Angular Velocity Integration
After computing applied torque, update angular velocity:
α = I⁻¹ × τ_applied # Angular acceleration (rad/s²)
ω(t + Δt) = ω(t) + α × Δt # Euler integration for angular velocity
Where I⁻¹ is the inverse of the current inertia tensor (recalculated each tick based on fuel level).
Inertia tensor singularity guard: Before inverting the inertia tensor, check the condition number. If the tensor is singular or near-singular (condition number > 1e10), log a warning with the ship ID and use the identity matrix as a fallback inertia tensor. This prevents a LinAlgError crash that would halt the entire physics tick for all players due to one misconfigured ship.
Attitude Quaternion Integration
Update the attitude quaternion from angular velocity:
# Angular velocity as a pure quaternion (w=0)
ω_quat = [0, ωx, ωy, ωz]
# Quaternion derivative
q̇ = 0.5 × q ⊗ ω_quat
# Integrate (Euler method)
q(t + Δt) = q(t) + q̇ × Δt
# Normalize to maintain unit quaternion
q(t + Δt) = normalize(q(t + Δt))
Quaternion multiplication ⊗ is defined as:
[w1,x1,y1,z1] ⊗ [w2,x2,y2,z2] = [
w1×w2 - x1×x2 - y1×y2 - z1×z2,
w1×x2 + x1×w2 + y1×z2 - z1×y2,
w1×y2 - x1×z2 + y1×w2 + z1×x2,
w1×z2 + x1×y2 - y1×x2 + z1×w2
]
Quaternion normalization:
normalize(q) = q / |q|
where |q| = sqrt(w² + x² + y² + z²)
Zero-magnitude quaternion guard: If the quaternion magnitude is below 1e-10 during normalization, the identity quaternion [1, 0, 0, 0] is returned as a safe fallback. A warning must be logged with the quaternion components when this occurs, as it indicates an upstream numerical issue (e.g., NaN propagation or degenerate integration) that should be diagnosed.
Future Releases
The following are deferred to later releases:
- Autopilot / navigation commands
- Collision detection and damage
- Combat systems
- Ship repair
Exceptions to Real Physics
- Jump drives/gates: FTL travel via discrete jumps (future release)
Galaxy Structure
Initial release includes only the Sol system:
- Sun
- Planets: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, Neptune
- Major moons:
- Earth: Luna
- Mars: Phobos, Deimos
- Jupiter: Io, Europa, Ganymede, Callisto
- Saturn: Mimas, Enceladus, Tethys, Dione, Rhea, Titan, Iapetus
- Uranus: Miranda, Ariel, Umbriel, Titania, Oberon
- Neptune: Triton
- Asteroids: Future release
Celestial Body Properties
| Body | Type | Parent | Mass (kg) | Radius (m) | Color (Hex) |
|---|---|---|---|---|---|
| Sun | star | — | 1.989 × 10³⁰ | 6.960 × 10⁸ | #FDB813 |
| Mercury | planet | Sun | 3.301 × 10²³ | 2.440 × 10⁶ | #B5A7A7 |
| Venus | planet | Sun | 4.867 × 10²⁴ | 6.052 × 10⁶ | #E6C87A |
| Earth | planet | Sun | 5.972 × 10²⁴ | 6.371 × 10⁶ | #6B93D6 |
| Luna | moon | Earth | 7.342 × 10²² | 1.737 × 10⁶ | #C0C0C0 |
| Mars | planet | Sun | 6.417 × 10²³ | 3.390 × 10⁶ | #C1440E |
| Phobos | moon | Mars | 1.066 × 10¹⁶ | 1.107 × 10⁴ | #8B7355 |
| Deimos | moon | Mars | 1.476 × 10¹⁵ | 6.200 × 10³ | #8B7355 |
| Jupiter | planet | Sun | 1.898 × 10²⁷ | 6.991 × 10⁷ | #D8CA9D |
| Io | moon | Jupiter | 8.932 × 10²² | 1.822 × 10⁶ | #FFFF00 |
| Europa | moon | Jupiter | 4.800 × 10²² | 1.561 × 10⁶ | #B0C4DE |
| Ganymede | moon | Jupiter | 1.482 × 10²³ | 2.634 × 10⁶ | #8B8682 |
| Callisto | moon | Jupiter | 1.076 × 10²³ | 2.410 × 10⁶ | #5C4033 |
| Saturn | planet | Sun | 5.683 × 10²⁶ | 5.823 × 10⁷ | #F4D59E |
| Mimas | moon | Saturn | 3.749 × 10¹⁹ | 1.980 × 10⁵ | #C0C0C0 |
| Enceladus | moon | Saturn | 1.080 × 10²⁰ | 2.520 × 10⁵ | #FFFAFA |
| Tethys | moon | Saturn | 6.175 × 10²⁰ | 5.310 × 10⁵ | #C0C0C0 |
| Dione | moon | Saturn | 1.095 × 10²¹ | 5.615 × 10⁵ | #C0C0C0 |
| Rhea | moon | Saturn | 2.307 × 10²¹ | 7.638 × 10⁵ | #C0C0C0 |
| Titan | moon | Saturn | 1.345 × 10²³ | 2.575 × 10⁶ | #E2A639 |
| Iapetus | moon | Saturn | 1.806 × 10²¹ | 7.346 × 10⁵ | #8B4513 |
| Uranus | planet | Sun | 8.681 × 10²⁵ | 2.536 × 10⁷ | #D1E7E7 |
| Miranda | moon | Uranus | 6.590 × 10¹⁹ | 2.358 × 10⁵ | #C0C0C0 |
| Ariel | moon | Uranus | 1.353 × 10²¹ | 5.789 × 10⁵ | #C0C0C0 |
| Umbriel | moon | Uranus | 1.172 × 10²¹ | 5.847 × 10⁵ | #696969 |
| Titania | moon | Uranus | 3.527 × 10²¹ | 7.884 × 10⁵ | #C0C0C0 |
| Oberon | moon | Uranus | 3.014 × 10²¹ | 7.614 × 10⁵ | #C0C0C0 |
| Neptune | planet | Sun | 1.024 × 10²⁶ | 2.462 × 10⁷ | #5B5DDF |
| Triton | moon | Neptune | 2.139 × 10²² | 1.353 × 10⁶ | #B0E0E6 |
Celestial Body Initialization
- Initial positions and velocities from ephemeris data (e.g., JPL Horizons)
- After initialization, bodies progress via numerical integration
- Game state may diverge from real-world ephemeris over time
Fresh Start vs Restart
| Scenario | Method | Source | Overwrites Redis |
|---|---|---|---|
| Fresh start (no game state in Redis) | InitializeBodies |
Ephemeris (JPL Horizons via galaxy service) | Yes (creates initial state) |
| Restart (existing game state in Redis) | RestoreBodies |
Redis (evolved positions) | No (loads into memory only) |
| Snapshot restore | RestoreBodies |
Redis (snapshot positions restored first) | No (loads into memory only) |
Fresh start: When no game state exists in Redis (first boot or after reset), tick-engine calls InitializeBodies which fetches ephemeris data for the current date and writes body positions/velocities to Redis.
Restart: When game state already exists in Redis (service restart, deployment), tick-engine calls RestoreBodies which loads the current body positions/velocities from Redis into the physics service’s memory without overwriting them. This preserves the evolved positions that have diverged from ephemeris since the game started, and maintains consistency with ship positions that remain in Redis.
Snapshot restore: When a snapshot is restored, the snapshot data is first written to Redis (overwriting current state), then RestoreBodies is called to reload the restored positions into the physics service’s memory. Without this step, the physics engine continues with stale in-memory positions that are permanently desynchronized from the restored Redis state.
Why this matters: If InitializeBodies is called on restart, body positions are reset to fresh ephemeris values while ship positions remain at their evolved values. A ship in Earth orbit would suddenly see Earth at a different position, breaking the orbital geometry and sending the ship on an escape trajectory.
Numerical Integration
- Method: Leapfrog (Störmer-Verlet) symplectic integrator
- Rationale: Preserves energy over long time periods, essential for stable orbits
- Order: 2nd order accuracy
- Time step: Tick duration (configurable, default 1 second)
Leapfrog update:
v(t + ½Δt) = v(t) + ½Δt × a(t)
x(t + Δt) = x(t) + Δt × v(t + ½Δt)
a(t + Δt) = compute_gravity(x(t + Δt))
v(t + Δt) = v(t + ½Δt) + ½Δt × a(t + Δt)
Integration Error Tracking
The physics service computes conserved quantities (total energy and angular momentum) for celestial bodies after each leapfrog step. These are compared to initial values to measure numerical drift.
Conserved Quantities
| Quantity | Formula | Units |
|---|---|---|
| Kinetic energy | KE = (1/2) * sum(m_i * |v_i|^2) | J |
| Potential energy | PE = -G * sum_{i<j}(m_i * m_j / r_ij) | J |
| Total energy | E = KE + PE | J |
| Angular momentum | L = sum(m_i * r_i x v_i) | kg*m^2/s |
Drift Metrics
| Metric | Definition | Interpretation |
|---|---|---|
| energy_drift | (E - E0) / |E0| | Relative energy error (dimensionless) |
| momentum_drift | |L - L0| / |L0| | Relative angular momentum error (dimensionless) |
Where E0 and L0 are initial values captured at the first tick after initialization.
Initial Value Management
- E0 and L0 are computed on the first tick after body initialization
- Stored in Redis as
game:energy_initialandgame:momentum_initial_{x,y,z} - Reset when: game reset, snapshot restore, or tick-engine restart with uninitialized state
Expected Drift Ranges
| Drift Level | Interpretation |
|---|---|
| < 1e-10 | Machine precision, excellent |
| 1e-10 to 1e-6 | Normal for leapfrog |
| 1e-6 to 1e-3 | Elevated, may indicate large timestep |
| > 1e-3 | Concerning, investigate timestep or bug |
Ships are excluded from conserved quantity tracking because they have thrust (non-conservative forces).
Coordinate System
The game uses Cartesian coordinates (x, y, z) as the primary internal representation.
- Units: 1 unit = 1 meter (base unit)
- Scale range: Ship-sized (~10m) to solar system (~10^13m)
- Precision: 64-bit floating point (sufficient for ~15 significant digits)
- Origin: Solar System Barycenter (SSB) — center of mass of the solar system
- Axes: International Celestial Reference Frame (ICRF) aligned
- x: Toward vernal equinox
- y: Completes right-handed system (in ecliptic plane)
- z: Toward celestial north pole
Conversion utilities will be provided for:
- Spherical coordinates: For display and navigation at galactic scale
- Local coordinates: Relative positioning (e.g., docking, orbiting)
Overview
The tick processor advances the game state at regular intervals, independent of player actions.
Configuration
| Parameter | Type | Default | Range | Description |
|---|---|---|---|---|
| tick_rate | float | 1.0 | 0.1 - 100.0 | Ticks per second |
| time_scale | float | 1.0 | 0.1 - 100.0 | Game speed multiplier (1.0 = real-time) |
| start_date | datetime | Current UTC | 1550-2650 AD | Ephemeris initialization date (bodies only; game time always tracks real UTC at time_scale 1.0) |
| snapshot_interval | int | 60 | 10 - 3600 | Seconds between state snapshots to PostgreSQL |
Runtime Changeability
| Parameter | Runtime Changeable | Storage |
|---|---|---|
| tick_rate | Yes (admin API) | Redis game:tick_rate, game_config table |
| time_scale | Yes (admin API) | Redis game:time_scale, game_config table |
| start_date | No (fresh start only) | Used for ephemeris initialization only |
| snapshot_interval | No (ConfigMap only) | Read from ConfigMap at startup |
Note: snapshot_interval cannot be changed at runtime in MVP. To change it, update the ConfigMap and restart tick-engine. Future releases may add an admin API endpoint.
Tick Rate Constraints
| Limit | Value | Rationale |
|---|---|---|
| Minimum | 0.1 Hz | 1 tick per 10 seconds; slower is impractical |
| Maximum | 100 Hz | Higher rates give finer integration; client updates capped at 10 Hz |
| Default | 1.0 Hz | Real-time simulation |
- Rate of 0 is not allowed — use pause instead
- Rate change takes effect on the next tick boundary
- Invalid rates are rejected with error
Time Scale Constraints
| Limit | Value | Rationale |
|---|---|---|
| Minimum | 0.1 | 10x slow motion; slower is impractical |
| Maximum | 100.0 | 100x fast-forward; faster risks physics instability |
| Default | 1.0 | Real-time simulation |
- Time scale of 0 is not allowed — use pause instead
- Time scale change takes effect on the next tick boundary
-
Invalid values are rejected with error E029
- Configuration is set by server admins only
- All players share the same time scale
- Valid date range: 1550 AD to 2650 AD (JPL DE440 ephemeris coverage)
Physics Time Step (dt)
The physics time step dt is derived from both tick rate and time scale:
dt = time_scale / tick_rate
| tick_rate | time_scale | dt | Meaning |
|---|---|---|---|
| 1.0 Hz | 1.0 | 1.0s | Real-time, 1 physics step/sec |
| 2.0 Hz | 1.0 | 0.5s | Real-time, finer physics (2 steps/sec) |
| 1.0 Hz | 10.0 | 10.0s | 10x fast-forward, 1 step/sec |
| 2.0 Hz | 10.0 | 5.0s | 10x fast-forward, finer physics |
| 10.0 Hz | 1.0 | 0.1s | Real-time, fine physics |
| 100.0 Hz | 1.0 | 0.01s | Real-time, finest physics |
Sub-Stepping
When dt > MAX_SUB_DT (10.0 seconds), the tick loop splits the tick into multiple sub-steps so that physics and automation always see a small time step, regardless of time scale.
MAX_SUB_DT = 10.0 # seconds
n_steps = max(1, ceil(dt / MAX_SUB_DT))
sub_dt = dt / n_steps
Each sub-step:
- Calls
physics.ProcessTick(tick, sub_dt)— physics integrates with the small dt - Calls
automation.evaluate_all_ships(tick)— automation seeseffective_dt = sub_dt - Advances
game_timebysub_dtseconds
The tick number increments only once (all sub-steps use the same tick number). The tick.completed event fires once at the end of the full tick.
| dt | n_steps | sub_dt | Behavior |
|---|---|---|---|
| 1.0s (1x) | 1 | 1.0s | No sub-stepping |
| 5.0s (5x) | 1 | 5.0s | No sub-stepping |
| 10.0s (10x) | 1 | 10.0s | No sub-stepping |
| 50.0s (50x) | 5 | 10.0s | 5 sub-steps |
| 100.0s (100x) | 10 | 10.0s | 10 sub-steps |
Automation effective_dt: Automation reads tick_state.effective_dt (an in-memory property set by the tick loop before each sub-step) instead of querying get_time_scale() from Redis. This gives automation the actual physics dt per sub-step, making all dt-dependent calculations (throttle scaling, ETA, dv capacity) correct regardless of time scale.
Circularize gain damping: Even with sub-stepping capping dt at 10s, the circularize proportional gain must scale inversely with dt. At dt=1s (1x), CIRCULARIZE_GAIN=1.0 works because thrust capacity (accel×dt=28 m/s) naturally limits the per-tick correction. At dt=10s, applying the full dv_rem in one sub-step overshoots. Gain damping ts_factor = min(1.0, CIRC_GAIN_DT_REF / dt) with CIRC_GAIN_DT_REF=1.0 ensures each sub-step applies 1/10 of dv_rem; over 10 sub-steps the total correction matches 1x behavior.
Tick Sequence
Each tick processes the following in order:
- Snapshot Pre-Update Bodies — Deep copy body state before N-body integration
- Update Celestial Bodies — Leapfrog integration for N-body simulation (modifies bodies in-place to time T+dt)
- Update Ships — For each ship: attitude control, fuel consumption, Leapfrog integration with thrust
Pre-update body positions: Before the N-body integration advances bodies from time T to T+dt, a snapshot of body state at time T is preserved. Ship processing uses these pre-update positions for:
- Initial leapfrog half-kick — The ship’s first half-step velocity update uses gravity from body positions at time T, consistent with the ship’s position at time T.
- Attitude reference body lookup — Reference body selection via Hill sphere containment must use body positions consistent with the ship’s position (both at time T). Fast-orbiting moons can move further than their Hill sphere radius in a single tick (e.g., Phobos: 16.7 km SOI, 2.1 km/s orbital velocity), causing the ship to falsely appear outside the SOI if post-update positions are used.
The ship’s second half-kick (after position update) uses post-update body positions (time T+dt). This creates a time-symmetric Strang splitting: at the start of the step, ship and bodies are both at time T; at the end, both are at time T+dt. Using the same (stale) body positions for both kicks is only symplectic for a static field — when the field source moves between ticks, the frozen-field approach creates a systematic energy drift proportional to body velocity × dt. For a ship in 100 km Luna orbit at 1× time scale, this drift was measured at ~6.7% per orbit (~12.9 J/kg/s), far exceeding physical tidal perturbation (~0.04%/orbit).
Ship Gravity
Ships experience gravitational acceleration from all celestial bodies:
a_gravity = Σ (G × M_body / |r|³) × r
where r = r_body - r_ship (vector from ship to body)
Ship Total Acceleration
Ships combine gravity with thrust:
# Thrust direction in world coordinates
thrust_direction = attitude.rotate([0, 0, 1]) # +Z axis in ship frame
# Cosine-gate thrust by alignment with commanded direction (#1005):
# Prevents off-axis thrust when attitude hasn't converged to target.
# At 0° misalignment: full thrust. At 90°: zero. Smooth transition.
target_direction = attitude_target.rotate([0, 0, 1])
cos_align = max(0, dot(thrust_direction, target_direction))
# Thrust acceleration (only if fuel > 0)
a_thrust = (max_thrust × thrust_level × cos_align / mass) × thrust_direction
# Total acceleration
a_total = a_gravity + a_thrust
Ship Leapfrog Integration
Ships use the same Leapfrog integrator as celestial bodies, with thrust added:
# Half-step velocity update (gravity from PRE-UPDATE body positions at time T)
a_gravity(t) = compute_gravity(ship_pos(t), pre_update_bodies)
a_total(t) = a_gravity(t) + a_thrust
v(t + ½Δt) = v(t) + ½Δt × a_total(t)
# Full-step position update
x(t + Δt) = x(t) + Δt × v(t + ½Δt)
# Recompute acceleration at new position (POST-UPDATE bodies for Strang splitting)
a_gravity(t + Δt) = compute_gravity(x(t + Δt), post_update_bodies)
a_total(t + Δt) = a_gravity(t + Δt) + a_thrust
# Complete velocity update
v(t + Δt) = v(t + ½Δt) + ½Δt × a_total(t + Δt)
Note: Thrust is assumed constant during the tick (thrust_level doesn’t change mid-tick).
Strang splitting for body-ship coupling: The first gravity evaluation uses pre-update body positions (time T), matching the ship’s position at time T. The second uses post-update body positions (time T+dt), matching the ship’s new position at time T+dt. This time-symmetric (Strang) splitting preserves the symplectic-like quality of the combined body-ship evolution and eliminates the systematic energy drift that occurs when both kicks use the same stale body positions.
Using the same body positions for both kicks is only symplectic for a static gravitational field. When the field source moves (Luna orbits Earth at ~966 m/s), the frozen-field approach creates a first-order splitting error that accumulates secularly. Measured drift: ~6.7% per orbit for a 100 km Luna orbit at 1× time scale.
Fuel Consumption (per tick)
fuel_requested = fuel_rate × thrust_level × tick_duration
fuel_consumed = min(fuel_requested, current_fuel)
current_fuel = current_fuel - fuel_consumed
mass = dry_mass + current_fuel
Mass is recalculated every tick before physics calculations to ensure accuracy.
Partial fuel edge case:
When remaining fuel is less than requested consumption:
# Example: 0.5 kg fuel remaining, 2.55 kg/s consumption rate at full thrust
fuel_requested = 2.55 × 1.0 × 1.0 = 2.55 kg
fuel_consumed = min(2.55, 0.5) = 0.5 kg
# Thrust is applied proportionally for the fraction that fuel lasts
effective_thrust_fraction = fuel_consumed / fuel_requested
= 0.5 / 2.55
= 0.196
# Apply thrust at full level but only for effective_thrust_fraction of the tick
# This is equivalent to reducing thrust_level for physics calculations
effective_thrust = max_thrust × thrust_level × effective_thrust_fraction
This ensures smooth fuel exhaustion rather than abrupt cutoff, and conserves momentum correctly.
Maneuver Telemetry
Multi-hour autonomous maneuvers (rendezvous, orbit matching) require historical visibility beyond the live status_text in Redis. The tick-engine provides structured milestone logging, a verbose toggle, and a Redis stream for maneuver history.
Milestone Logging (Always On)
Milestone events are logged via structlog at INFO level with a maneuver_event field for filtering.
| Event | When | Key Fields |
|---|---|---|
maneuver_started |
Maneuver begins | type, ref_body, target_id (if rendezvous) |
maneuver_completed |
Maneuver finishes | type, duration_ticks, final element errors |
maneuver_aborted |
Player/system aborts | type, reason, duration_ticks |
phase_transition |
Phase changes (e.g., adjust_orbit to approach) | from_phase, to_phase, element errors |
burn_started |
Thrust goes from 0 to >0 | phase, thrust_level, steering direction |
burn_ended |
Thrust goes from >0 to 0 | phase, burn_duration_ticks, delta_v_estimate |
Verbose Toggle
A global admin setting controls additional periodic logging during maneuvers.
| Setting | Redis Key | Values | Default |
|---|---|---|---|
| Global level | game:maneuver_logging |
"verbose", "normal", "off" |
"normal" |
| Per-maneuver override | verbose_log field in maneuver:{ship_id} |
"true", "false" |
not set (uses global) |
Verbose logging (when enabled): Every 300 ticks (~5 min at 1 Hz), log current element errors, effectivity, thrust level, cumulative delta-v, and steering direction. Uses maneuver_event: "verbose_status".
Normal logging: Only milestone events (phase transitions, start, complete, abort).
Off: No maneuver logging at all (not recommended for production).
Redis Stream for History
Milestone events are written to per-ship Redis streams for client retrieval and historical analysis.
| Parameter | Value | Description |
|---|---|---|
| Stream key | maneuver_log:{ship_id} |
Per-ship maneuver history |
| Max length | ~1000 entries | Auto-trimmed via XADD ... MAXLEN ~ 1000 |
| Fields | timestamp, event_type, phase, element_errors, delta_v, details |
Structured event data |
Admin API
| Method | Path | Body | Description |
|---|---|---|---|
| GET | /api/admin/maneuver-logging |
— | Returns {"level": "normal"} |
| POST | /api/admin/maneuver-logging |
{"level": "verbose"\|"normal"\|"off"} |
Set global logging level |
Future Releases
- Combat resolution
- Resource production
Timing Behavior
- Ticks are scheduled at fixed intervals based on
tick_rate - If tick processing exceeds the interval duration:
- Process missed ticks as fast as possible (catch up)
- Maintain tick counter accuracy — no ticks are skipped
- Resume normal timing once caught up
State
current_tick: Integer counter, increments each ticktick_timestamp: UTC timestamp of tick executiongame_time: Simulated date/time in the game world
Game Time Calculation
Game time advances incrementally each tick based on the physics time step dt:
# Each tick advances game time by dt seconds
dt = time_scale / tick_rate
game_time = game_time + timedelta(seconds=dt)
Behavior:
- When running:
game_timeadvances bydtseconds each tick - When paused:
game_timefreezes at the value from the last processed tick - When resumed:
game_timecontinues from where it was (no jump)
Pause flag sync (#1020): The tick loop caches game:paused for performance. When paused, it polls Redis every ~2s to detect external unpauses (e.g., admin API called on a different pod, or direct Redis write during recovery). The pause() and resume() gRPC methods update the cache immediately for instant response.
- On reset:
game_timeis set to the start date
Storage:
game_timeis stored in Redis (updated incrementally each tick)- Persisted in snapshots for recovery
- Initialized to start date on fresh start
Tick rate, time scale, and game time:
Tick rate controls simulation frequency. Time scale controls game speed. Together they determine dt:
| tick_rate | time_scale | dt | Game time per real second |
|---|---|---|---|
| 1.0 Hz | 1.0 | 1.0s | 1 second (real-time) |
| 2.0 Hz | 1.0 | 0.5s | 1 second (real-time, finer physics) |
| 0.5 Hz | 1.0 | 2.0s | 1 second (real-time, coarser physics) |
| 1.0 Hz | 10.0 | 10.0s | 10 seconds (10x fast-forward) |
| 2.0 Hz | 10.0 | 5.0s | 10 seconds (10x fast-forward, finer physics) |
Higher tick rates give finer-grained physics simulation, not faster time. Higher time scales give faster time.
Game Time Synchronization
When time_scale is 1.0 (real-time), game time should track wall-clock UTC. However, drift accumulates from tick processing delays, pauses, and restarts. A proportional controller gently adjusts the effective time scale to converge game time back to UTC.
Correction Formula
drift = (utc_now - game_time).total_seconds() # positive = game behind UTC
tick_duration = 1.0 / tick_rate # seconds per tick
if time_sync_enabled and abs(admin_time_scale - 1.0) < 0.001:
if abs(drift) <= 10.0: # deadband (seconds)
effective_time_scale = 1.0
else:
correction = clamp(drift / (tick_duration * 20), -0.05, 0.05)
effective_time_scale = 1.0 + correction
else:
effective_time_scale = admin_time_scale # no sync; at non-1.0 time scales,
# game time intentionally diverges from UTC
The correction is proportional to drift / (tick_duration * 20). This means:
- At 1 Hz tick rate:
tick_duration = 1.0, divisor = 20, so 30s drift gives correction = 1.5 -> capped at 0.05 (1.05x) - At 10 Hz tick rate:
tick_duration = 0.1, divisor = 2, so 30s drift gives correction = 15 -> capped at 0.05 (1.05x) - The
tick_duration * 20divisor ensures the correction is meaningful relative to how much time each tick covers
Why tick-rate-aware? The old formula drift / 1000.0 was a fixed proportional gain that didn’t account for tick duration. At default 1 Hz, a 1000s drift only produced a 0.05 correction (5%), meaning it would take 20,000 seconds (~5.5 hours) to close the gap. The tick-rate-aware formula converges faster because the correction scales with how quickly ticks are processed.
Configuration
| Parameter | Value | Description |
|---|---|---|
| Deadband | 10 seconds | Drift within this range is ignored |
| Convergence factor | 20 ticks | Number of ticks over which to spread the correction |
| Max correction | ±5% | Effective scale clamped to 0.95–1.05 |
Examples (at default 1 Hz tick rate)
| Drift | Correction | Effective Scale | Meaning |
|---|---|---|---|
| +5s | 0 (deadband) | 1.0 | Within tolerance |
| +10s | 0 (deadband) | 1.0 | At deadband edge |
| +11s | +0.05 (capped) | 1.05 | Just outside deadband, max correction |
| +30s | +0.05 (capped) | 1.05 | Game behind, max speed up |
| +200s | +0.05 (capped) | 1.05 | Large drift, still capped |
| -30s | -0.05 (capped) | 0.95 | Game ahead, max slow down |
Behavior
- Sync only applies when admin
time_scale≈ 1.0: If admin sets time_scale to 10.0 (fast-forward), sync defers automatically. This is intentional — at non-1.0 time scales, game time diverges from UTC by design (e.g., at 2x speed, game time advances 2 seconds per real second). Comparing game time to UTC at non-1.0 time scales would produce meaningless corrections that fight the intended speed multiplier. - When sync disabled: Existing behavior — admin controls time_scale freely.
- After pause: Drift grows by pause duration; sync catches up smoothly at up to 1.05x. No time jumps.
- Default: Enabled on fresh start.
- Storage:
game:time_syncin Redis, persisted to PostgreSQLgame_configtable. - Drift telemetry: Current drift stored in
game:time_driftfor dashboard display.
Events
The tick processor emits events after each tick:
tick.completed— Tick finished processingtick_number: The tick that completedduration_ms: Processing time in milliseconds
Circuit Breaker
The tick-engine wraps physics service (ProcessTick) calls with a circuit breaker to prevent repeated RPC attempts when physics is persistently unavailable.
States
| State | Behavior |
|---|---|
| CLOSED | Normal operation. Requests pass through to physics. Failures are counted. |
| OPEN | Physics presumed down. Requests fail immediately (no RPC attempt). |
| HALF_OPEN | Probing. One request is allowed through to test recovery. |
State Transitions
CLOSED → (failure_threshold consecutive failures) → OPEN
OPEN → (open_duration elapsed) → HALF_OPEN
HALF_OPEN → (success) → CLOSED
HALF_OPEN → (failure) → OPEN (timer reset)
Configuration
| Parameter | Type | Default | Description |
|---|---|---|---|
| circuit_breaker_failure_threshold | int | 5 | Consecutive failures before opening circuit |
| circuit_breaker_open_duration_seconds | float | 30.0 | Seconds to wait in OPEN before probing |
Integration with Tick Processing
- CLOSED:
_process_tick()calls physics normally. On success, failure count resets. On all retries exhausted, failure count increments. When threshold reached, circuit opens. - OPEN:
_process_tick()returns -1 immediately with a DEBUG log (no RPC, no error spam). The existing auto-pause mechanism pauses the game when the circuit is open. - HALF_OPEN:
allow_request()returns True once (after open_duration elapses). If the probe succeeds, circuit closes. If it fails, circuit reopens with timer reset.
gRPC Timeout Handling
gRPC DEADLINE_EXCEEDED errors (timeouts) are not counted as circuit breaker failures. Timeouts indicate transient network latency, not a service-level failure. Counting them would cause 5 slow responses to trigger auto-pause even though the physics service is healthy.
| gRPC Status Code | Circuit Breaker Action | Rationale |
|---|---|---|
| DEADLINE_EXCEEDED | Log warning, do NOT record failure | Transient network blip |
| UNAVAILABLE | Record failure (after all retries) | Service is down |
| INTERNAL | Record failure (after all retries) | Service error |
| Other errors | Record failure (after all retries) | Unexpected failure |
When a DEADLINE_EXCEEDED error occurs on all retry attempts, _process_tick() still returns -1 (tick failed), but the circuit breaker failure count is not incremented. The tick will be retried on the next loop iteration.
Integration with Health Check
The existing health check loop (probes every 5s, requires 5 consecutive successes) serves as the recovery mechanism. On recovery:
- RestoreBodies: Calls
physics.RestoreBodies()to re-initialize physics from Redis state. This handles the case where the physics service restarted and lost its in-memory state (E017 “not initialized”).RestoreBodiesis idempotent — safe to call even if physics is already initialized. - Circuit breaker reset: Calls
circuit_breaker.reset()to force the circuit back to CLOSED. - Auto-resume: If the tick-engine auto-paused due to the outage, it auto-resumes ticking.
If RestoreBodies fails, the health check does not mark physics as recovered and does not resume ticking. The next health check cycle will retry.
Integration with Initialize/Restore
On successful initialize() or restore(), the circuit breaker is reset to CLOSED.