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

  1. Reaction wheels apply torque first (no fuel cost)
  2. RCS supplements when wheels insufficient or saturated
  3. 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:

  1. Pending rotation_input is cleared (zeroed)
  2. 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_initial and game: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:

  1. Calls physics.ProcessTick(tick, sub_dt) — physics integrates with the small dt
  2. Calls automation.evaluate_all_ships(tick) — automation sees effective_dt = sub_dt
  3. Advances game_time by sub_dt seconds

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:

  1. Snapshot Pre-Update Bodies — Deep copy body state before N-body integration
  2. Update Celestial Bodies — Leapfrog integration for N-body simulation (modifies bodies in-place to time T+dt)
  3. 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 tick
  • tick_timestamp: UTC timestamp of tick execution
  • game_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_time advances by dt seconds each tick
  • When paused: game_time freezes at the value from the last processed tick
  • When resumed: game_time continues 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_time is set to the start date

Storage:

  • game_time is 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 * 20 divisor 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_sync in Redis, persisted to PostgreSQL game_config table.
  • Drift telemetry: Current drift stored in game:time_drift for dashboard display.

Events

The tick processor emits events after each tick:

  • tick.completed — Tick finished processing
    • tick_number: The tick that completed
    • duration_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:

  1. 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”). RestoreBodies is idempotent — safe to call even if physics is already initialized.
  2. Circuit breaker reset: Calls circuit_breaker.reset() to force the circuit back to CLOSED.
  3. 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.


Back to top

Galaxy — Kubernetes-based multiplayer space game

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