WebSocket API
Real-time bidirectional protocol for game state streaming and ship control.
Transport
| Parameter | Value |
|---|---|
| Protocol | WSS (WebSocket over TLS) |
| Endpoint | wss://galaxy.example.com/ws |
| Development | wss://localhost:30002/ws |
| Max message size | 64 KB |
| Subprotocol | None (raw JSON frames) |
Connection Lifecycle
- Client opens WebSocket to
/ws(no token in URL) - Server validates Origin header (see Origin Validation below)
- Client starts a 10-second connection timeout
- On open, client sends
authmessage:{"type": "auth", "token": "<jwt>"} - Server validates token; on failure closes with close code (see below)
- Server sends
welcomewith player info — client clears connection timeout - Server immediately sends first
statemessage with current game state - Server sends
stateupdates each tick (rate-limited to 10 Hz during catch-up) - Client sends
pingevery 30 seconds; server responds withpong - If no
pongreceived within 10 seconds, client should reconnect
Tokens are never sent in URL query strings to avoid exposure in HTTP access logs, browser history, and proxy logs. The server closes the connection (code 4001) if no valid auth message arrives within 5 seconds of the WebSocket upgrade.
Origin Validation (CSRF Protection)
WebSocket connections bypass standard CORS protections. Before calling websocket.accept(), the server must validate the Origin header to prevent cross-site WebSocket hijacking (CSRF).
Validation rules:
| Condition | Action |
|---|---|
No Origin header present |
Allow — non-browser clients (game clients, test tools) may not send Origin |
| Origin matches allowed origins | Allow |
Origin is a localhost variant |
Allow — development convenience (http://localhost:*, https://localhost:*) |
| Origin does not match | Reject — close with code 4003 (“Origin not allowed”) before accepting |
Allowed origins are derived from the existing cors_origins configuration setting (same origins used for HTTP CORS). The server parses the comma-separated cors_origins string into a set at startup.
localhost detection: Any origin where the hostname is localhost or 127.0.0.1 is allowed regardless of port or scheme. This covers development scenarios where the web client runs on varying ports.
Implementation: Check origin before websocket.accept(). If rejected, call websocket.close(code=4003, reason="Origin not allowed") without accepting the connection.
If the connection timeout fires (WebSocket does not reach OPEN state within 10 seconds), the client closes the socket and triggers the reconnection backoff sequence.
Ship-less Admin Mode
Admin accounts with ship_id = NULL (empty string in JWT) can connect via WebSocket for observation. The auth validation allows empty ship_id when the JWT’s is_admin claim is true.
Behavior:
- Welcome message includes
is_admin: trueandship_id: "" - State broadcasts include all bodies, ships, and stations, but
shipfield is null - Ship-dependent messages return error E030 “No ship assigned”:
control,service,attitude_hold,attitude_prograde,attitude_retrograde,attitude_normal,attitude_antinormal,attitude_radial,attitude_antiradial,attitude_local_horizontal,attitude_local_vertical,attitude_target,attitude_anti_target,attitude_target_prograde,attitude_target_retrograde,automation_create,automation_update,automation_delete,automation_list,maneuver_query,maneuver_abort,maneuver_pause,maneuver_resume,target_select,ship_rename
- Non-ship messages work normally:
ping,chat_send
Close Codes
| Code | Reason | Client Action |
|---|---|---|
| 4001 | Authentication failed (generic; specific reason logged server-side) | Display auth error, redirect to login (do not reconnect) |
| 4003 | Origin not allowed (CSRF protection) | Display error, do not reconnect |
| 4013 | Server full | Display “Server full, try again later” |
| 1001 | Server shutting down | Reconnect with backoff |
Server → Client Messages
welcome
Sent once on successful authentication.
{
"type": "welcome",
"player_id": "550e8400-e29b-41d4-a716-446655440000",
"ship_id": "660e8400-e29b-41d4-a716-446655440001",
"is_admin": false,
"config": {
"server_name": "Galaxy",
"tick_rate": 1.0,
"time_scale": 1.0,
"game_time": "2025-01-15T10:30:00Z",
"paused": false
},
"versions": {
"api_gateway": "1.10.1",
"physics": "1.10.1",
"tick_engine": "1.10.1",
"web_client": "1.10.1"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "welcome" |
| player_id | uuid | yes | Player’s unique identifier |
| ship_id | uuid | yes | Player’s ship unique identifier (empty string for ship-less admins) |
| is_admin | boolean | yes | True if player has admin role |
| config | object | yes | Current game configuration |
| config.server_name | string | yes | Server instance name (from SERVER_NAME env var, default "Galaxy") |
| config.tick_rate | number | yes | Current ticks per second (from Redis game:tick_rate) |
| config.time_scale | number | yes | Game speed multiplier, 1.0 = real-time (from Redis game:time_scale) |
| config.game_time | string | yes | Current game time, ISO 8601 (from Redis game:time) |
| config.paused | boolean | yes | Whether tick processing is paused (from Redis game:paused) |
| versions | object | yes | Service version strings (semver) |
| versions.api_gateway | string | yes | API gateway version |
| versions.physics | string | yes | Physics service version |
| versions.tick_engine | string | yes | Tick engine version |
| versions.web_client | string | yes | Web client version |
| system_id | string | yes | Star system of the player’s active ship (default "sol") |
Versions are fetched from each service’s /health/ready endpoint at api-gateway startup. The api-gateway’s own version comes from src/__init__.__version__.
If config.paused is true, the client should display “GAME PAUSED” overlay and expect no state updates until a game_resumed message arrives. Receipt of a state message implies the game is not paused; if the client’s pause state is stale (e.g., missed game_resumed during reconnect), it should self-correct.
state
Sent every tick with the full game state. During catch-up (server processing missed ticks), broadcasts are rate-limited to 10 Hz wall-clock.
{
"type": "state",
"tick": 123456,
"game_time": "2025-01-15T10:30:00Z",
"effective_time_scale": 1.0,
"players_online": 3,
"bodies": [
{
"name": "Earth",
"type": "planet",
"position": {"x": 1.496e11, "y": 0.0, "z": 0.0},
"velocity": {"x": 0.0, "y": 29780.0, "z": 0.0},
"mass": 5.972e24,
"radius": 6371000.0,
"rotation_period": 86164.1,
"axial_tilt": 23.44,
"prime_meridian_at_epoch": 0.0
}
],
"ship": {
"ship_id": "660e8400-e29b-41d4-a716-446655440001",
"player_id": "550e8400-e29b-41d4-a716-446655440000",
"name": "PlayerOne",
"position": {"x": 1.496e11, "y": 0.0, "z": 6771000.0},
"velocity": {"x": 0.0, "y": 29780.0, "z": 7672.0},
"attitude": {"w": 1.0, "x": 0.0, "y": 0.0, "z": 0.0},
"angular_velocity": {"x": 0.0, "y": 0.0, "z": 0.0},
"thrust_level": 0.0,
"fuel": 10000.0,
"fuel_capacity": 10000.0,
"mass": 20000.0,
"inertia_tensor": {"x": 320000.0, "y": 320000.0, "z": 80000.0},
"wheel_saturation": {"x": 0.0, "y": 0.0, "z": 0.0},
"attitude_hold": false,
"attitude_mode": "none",
"ship_class": "fast_frigate"
},
"ships": [
{
"ship_id": "770e8400-e29b-41d4-a716-446655440002",
"player_id": "880e8400-e29b-41d4-a716-446655440003",
"name": "OtherPlayer",
"position": {"x": 1.496e11, "y": 100000.0, "z": 6771000.0},
"velocity": {"x": 0.0, "y": 29780.0, "z": 7672.0},
"attitude": {"w": 1.0, "x": 0.0, "y": 0.0, "z": 0.0},
"angular_velocity": {"x": 0.0, "y": 0.0, "z": 0.0},
"thrust_level": 0.0,
"fuel": 10000.0,
"fuel_capacity": 10000.0,
"mass": 20000.0,
"inertia_tensor": {"x": 320000.0, "y": 320000.0, "z": 80000.0},
"ship_class": "fast_frigate"
}
],
"stations": [
{
"station_id": "990e8400-e29b-41d4-a716-446655440004",
"name": "Gateway Station",
"position": {"x": 1.496e11, "y": 0.0, "z": 1.1871e7},
"velocity": {"x": 0.0, "y": 29780.0, "z": 5790.0},
"attitude": {"w": 1.0, "x": 0.0, "y": 0.0, "z": 0.0},
"mass": 420000,
"radius": 50,
"parent_body": "Earth"
}
]
}
Top-level fields:
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "state" |
| tick | integer | yes | Current tick number |
| game_time | string | yes | Current game time (ISO 8601) |
| effective_time_scale | number | yes | Actual time scale applied (may differ from admin-set scale during time sync) |
| players_online | integer | yes | Number of connected players (from Redis SCARD connections:online) |
| bodies | array | yes | All celestial bodies (always 31 elements) |
| ship | object|null | yes | Player’s own ship (null if ship does not exist) |
| ships | array | yes | All other players’ ships (empty array if none) |
| stations | array | yes | All stations (empty array if none) |
Body fields:
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | yes | Body name (e.g., “Earth”, “Luna”) |
| type | string | yes | Body type: “star”, “planet”, or “moon” |
| position | vec3 | yes | Position in ICRF (meters) |
| velocity | vec3 | yes | Velocity in ICRF (m/s) |
| mass | number | yes | Mass (kg) |
| radius | number | yes | Mean radius (meters) |
| rotation_period | number | yes | Sidereal rotation period (seconds, negative = retrograde) |
| axial_tilt | number | yes | Obliquity (degrees) |
| prime_meridian_at_epoch | number | yes | Prime meridian angle at J2000 epoch (degrees) |
Own ship fields (includes private state):
| Field | Type | Required | Description |
|---|---|---|---|
| ship_id | uuid | yes | Ship unique identifier |
| player_id | uuid | yes | Owning player’s identifier |
| name | string | yes | Player display name |
| position | vec3 | yes | Position in ICRF (meters) |
| velocity | vec3 | yes | Velocity in ICRF (m/s) |
| attitude | quaternion | yes | Orientation quaternion (ICRF) |
| angular_velocity | vec3 | yes | Angular velocity (rad/s, body frame) |
| thrust_level | number | yes | Current throttle (0.0–1.0) |
| fuel | number | yes | Remaining propellant (kg) |
| fuel_capacity | number | yes | Maximum propellant capacity (kg) |
| mass | number | yes | Total mass including fuel (kg) |
| inertia_tensor | vec3 | yes | Diagonal inertia tensor (kg·m²) |
| wheel_saturation | vec3 | yes | Reaction wheel saturation per axis (0.0–1.0) |
| attitude_hold | boolean | yes | Whether attitude hold is enabled |
| attitude_mode | string | yes | Current attitude mode (see values below) |
Attitude mode values: "none", "hold", "prograde", "retrograde", "normal", "antinormal", "radial", "antiradial", "local_horizontal", "local_vertical", "target", "anti_target"
Other ship fields (excludes private state):
Same as own ship fields except wheel_saturation, attitude_hold, and attitude_mode are omitted. These are private internal state not shared with other players.
Station fields:
| Field | Type | Required | Description |
|---|---|---|---|
| station_id | uuid | yes | Station unique identifier |
| name | string | yes | Station display name |
| position | vec3 | yes | Position in ICRF (meters) |
| velocity | vec3 | yes | Velocity in ICRF (m/s) |
| attitude | quaternion | yes | Fixed orientation quaternion (always identity) |
| mass | number | yes | Station mass (kg) |
| radius | number | yes | Proximity envelope radius (meters) |
| parent_body | string | yes | Reference body name (e.g., “Earth”) |
Notes:
bodies,ships, andstationsarrays are always present (never omitted)- When no other players are online,
shipsis an empty array wheel_saturationis computed by api-gateway fromwheel_momentum:abs(wheel_momentum[axis]) / 10000massandfuel_capacityare computed by the physics service- Body
massis included in state messages (unlike the old spec which omitted it)
player_joined
Broadcast to all connected clients when another player connects.
{
"type": "player_joined",
"player_id": "550e8400-e29b-41d4-a716-446655440000",
"ship_id": "660e8400-e29b-41d4-a716-446655440001",
"name": "NewPlayer"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "player_joined" |
| player_id | uuid | yes | Joining player’s identifier |
| ship_id | uuid | yes | Joining player’s ship identifier |
| name | string | yes | Player display name (from JWT username claim) |
This indicates connection status, not ship creation. A player’s ship continues to exist in the simulation even when disconnected.
player_left
Broadcast to all connected clients when another player disconnects.
{
"type": "player_left",
"player_id": "550e8400-e29b-41d4-a716-446655440000",
"ship_id": "660e8400-e29b-41d4-a716-446655440001"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "player_left" |
| player_id | uuid | yes | Departing player’s identifier |
| ship_id | uuid | yes | Departing player’s ship identifier |
This indicates disconnection, not ship removal. The player’s ship continues to exist and move in the simulation. Ship removal only occurs when the player deletes their account, an admin deletes the player, or an admin performs a factory reset.
game_paused
Broadcast when an admin pauses the game.
{
"type": "game_paused",
"paused_at_tick": 123456
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "game_paused" |
| paused_at_tick | integer | yes | Tick number at which the game was paused |
Trigger: tick-engine publishes tick.paused event on the galaxy:tick Redis stream; api-gateway broadcasts to all WebSocket clients.
Client should display “GAME PAUSED” overlay and expect no further state updates until game_resumed.
game_resumed
Broadcast when an admin resumes the game.
{
"type": "game_resumed",
"resumed_at_tick": 123456
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "game_resumed" |
| resumed_at_tick | integer | yes | Tick number at which the game was resumed |
Trigger: tick-engine publishes tick.resumed event; api-gateway broadcasts to all WebSocket clients.
Client should hide “GAME PAUSED” overlay and expect state updates to resume.
game_restored
Broadcast when an admin restores from a snapshot.
{
"type": "game_restored",
"restored_to_tick": 123456,
"game_time": "2025-01-15T10:30:00Z"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "game_restored" |
| restored_to_tick | integer | yes | Tick number of the restored snapshot |
| game_time | string | yes | Game time at the restored snapshot (ISO 8601) |
Trigger: tick-engine publishes tick.restored event; api-gateway broadcasts to all WebSocket clients.
Status: Specified but not yet implemented in api-gateway event loop. The tick.restored event is published by tick-engine but not currently forwarded to WebSocket clients.
Client behavior (when implemented):
- Display notification: “Game state restored to [game_time]”
- Expect ship positions may have changed dramatically
- Next
statemessage will contain the restored positions - Players whose ships did not exist in the restored snapshot will receive E006 on their next control command
tick_rate_changed
Broadcast when an admin changes the tick rate.
{
"type": "tick_rate_changed",
"previous_rate": 1.0,
"new_rate": 2.0
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "tick_rate_changed" |
| previous_rate | number | yes | Previous tick rate (Hz) |
| new_rate | number | yes | New tick rate (Hz) |
Trigger: tick-engine publishes tick.rate_changed event; api-gateway broadcasts to all WebSocket clients.
time_scale_changed
Broadcast when an admin changes the time scale.
{
"type": "time_scale_changed",
"previous_scale": 1.0,
"new_scale": 10.0
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "time_scale_changed" |
| previous_scale | number | yes | Previous time scale multiplier |
| new_scale | number | yes | New time scale multiplier |
Trigger: tick-engine publishes tick.time_scale_changed event; api-gateway broadcasts to all WebSocket clients.
ship.spawned
Broadcast when a new ship is created (e.g., player registration or reset service).
{
"type": "ship.spawned",
"ship_id": "660e8400-e29b-41d4-a716-446655440001",
"player_id": "550e8400-e29b-41d4-a716-446655440000",
"name": "PlayerOne",
"position": {"x": 1.496e11, "y": 0.0, "z": 6771000.0}
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "ship.spawned" |
| ship_id | uuid | yes | New ship’s identifier |
| player_id | uuid | yes | Owning player’s identifier |
| name | string | yes | Ship display name (defaults to player username on spawn) |
| position | vec3 | yes | Initial spawn position (meters, ICRF) |
Trigger: physics service publishes ship.spawned event on the galaxy:ships Redis stream; api-gateway broadcasts to all WebSocket clients.
ship.removed
Broadcast when a ship is removed (e.g., account deletion).
{
"type": "ship.removed",
"ship_id": "660e8400-e29b-41d4-a716-446655440001",
"player_id": "550e8400-e29b-41d4-a716-446655440000"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "ship.removed" |
| ship_id | uuid | yes | Removed ship’s identifier |
| player_id | uuid | yes | Owning player’s identifier |
Trigger: physics service publishes ship.removed event on the galaxy:ships Redis stream; api-gateway broadcasts to all WebSocket clients.
ship.destroyed
Broadcast when a ship collides with a celestial body and is permanently destroyed. The ship is removed from Redis, the player’s fleet DB, and all client views. If it was the player’s active ship, the server automatically switches to another ship (if available).
{
"type": "ship.destroyed",
"ship_id": "660e8400-e29b-41d4-a716-446655440001",
"player_id": "550e8400-e29b-41d4-a716-446655440000",
"body": "Earth",
"reason": "collision"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "ship.destroyed" |
| ship_id | uuid | yes | Destroyed ship’s identifier |
| player_id | uuid | yes | Owning player’s identifier |
| body | string | yes | Name of body the ship crashed into |
| reason | string | yes | Destruction cause ("collision", "combat", etc.) |
Trigger: physics service detects dist(ship, body) < body.radius during tick processing, deletes the ship from Redis, publishes ship.destroyed event on the galaxy:ships Redis stream; api-gateway broadcasts to all WebSocket clients and notifies the players service to remove the ship from the fleet database.
station.spawned
Broadcast when a new station is created (admin action).
{
"type": "station.spawned",
"station_id": "990e8400-e29b-41d4-a716-446655440004",
"name": "Gateway Station",
"parent_body": "Earth"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "station.spawned" |
| station_id | uuid | yes | New station’s identifier |
| name | string | yes | Station display name |
| parent_body | string | yes | Reference body name |
Trigger: physics service publishes station.spawned event on the galaxy:stations Redis stream; api-gateway broadcasts to all WebSocket clients.
station.removed
Broadcast when a station is removed (admin action).
{
"type": "station.removed",
"station_id": "990e8400-e29b-41d4-a716-446655440004"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "station.removed" |
| station_id | uuid | yes | Removed station’s identifier |
Trigger: physics service publishes station.removed event on the galaxy:stations Redis stream; api-gateway broadcasts to all WebSocket clients.
jumpgate.spawned
Broadcast when a new jump gate is created (admin action).
{
"type": "jumpgate.spawned",
"jumpgate_id": "aa0e8400-e29b-41d4-a716-446655440005",
"name": "Earth-Luna L4 Gate",
"parent_body": "Earth"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "jumpgate.spawned" |
| jumpgate_id | uuid | yes | New jump gate’s identifier |
| name | string | yes | Jump gate display name |
| parent_body | string | yes | Reference body name |
Trigger: physics service publishes jumpgate.spawned event on the galaxy:jumpgates Redis stream; api-gateway broadcasts to all WebSocket clients.
jumpgate.removed
Broadcast when a jump gate is removed (admin action).
{
"type": "jumpgate.removed",
"jumpgate_id": "aa0e8400-e29b-41d4-a716-446655440005"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "jumpgate.removed" |
| jumpgate_id | uuid | yes | Removed jump gate’s identifier |
Trigger: physics service publishes jumpgate.removed event on the galaxy:jumpgates Redis stream; api-gateway broadcasts to all WebSocket clients.
versions_updated
Broadcast when service versions change (detected by polling every 60 seconds).
{
"type": "versions_updated",
"versions": {
"api_gateway": "1.10.1",
"physics": "1.10.1",
"tick_engine": "1.10.1",
"web_client": "1.10.1"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "versions_updated" |
| versions | object | yes | Updated service versions (same structure as welcome.versions) |
Trigger: api-gateway polls /health/ready on physics, tick-engine, and /version.json on web-client every 60 seconds. If any version changes, this message is broadcast.
Internal transport: Version polling uses cluster-internal plain HTTP (not HTTPS). TLS terminates at the ingress/NodePort for external traffic; pod-to-pod calls within the cluster use HTTP.
chat_message
Broadcast to all connected clients when a player sends a chat message.
{
"type": "chat_message",
"player_id": "550e8400-e29b-41d4-a716-446655440000",
"username": "PlayerOne",
"message": "Hello everyone!",
"timestamp": 1705315800
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "chat_message" |
| player_id | uuid | yes | Sender’s player identifier |
| username | string | yes | Sender’s display name |
| message | string | yes | Message text (1–256 characters, trimmed) |
| timestamp | number | yes | Server Unix time in seconds (epoch) |
Trigger: A connected client sends a valid chat_send message. The server broadcasts to ALL connected clients (including the sender).
targeted_by
Sent to a player when other players target or untarget their ship. Contains the full list of current targeters (not incremental). An empty targeters array means no one is targeting the ship.
{
"type": "targeted_by",
"targeters": [
{
"player_id": "550e8400-e29b-41d4-a716-446655440000",
"ship_id": "660e8400-e29b-41d4-a716-446655440001",
"name": "PlayerOne"
}
]
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "targeted_by" |
| targeters | array | yes | List of players currently targeting this ship (empty = no one) |
| targeters[].player_id | uuid | yes | Targeting player’s identifier |
| targeters[].ship_id | uuid | yes | Targeting player’s ship identifier |
| targeters[].name | string | yes | Targeting player’s display name |
Trigger: Another player sends a target_select message selecting or deselecting a ship target. The server maintains in-memory targeting state and sends the full targeter list whenever it changes for a given ship.
Edge cases:
- Targeter disconnects → removed from targeting state, target ship notified with updated list
- Target ship reconnects → receives current targeter list on connection
- Multiple targeters → all included in the array
pong
Response to a client ping.
{
"type": "pong",
"timestamp": 1705315800000
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "pong" |
| timestamp | integer | yes | Echoed timestamp from the client’s ping (Unix milliseconds) |
Used for round-trip latency measurement: RTT = Date.now() - pong.timestamp.
error
Sent when the server encounters an error processing a client message.
{
"type": "error",
"code": "E001",
"message": "Invalid rotation value"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "error" |
| code | string | yes | Error code (see Error Codes) |
| message | string | yes | Human-readable error description |
service_error
Sent when a ship service request (fuel, reset, dock, undock) fails at the physics layer (distinct from a gRPC transport error, which returns error).
{
"type": "service_error",
"service": "dock",
"code": "E031"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "service_error" |
| service | string | yes | Service that failed ("fuel", "reset", "dock", "undock") |
| code | string | yes | Error code from physics response |
Trigger: Client sends service message; physics gRPC RequestService returns success=false.
automation_created
Confirmation that an automation rule was created.
{
"type": "automation_created",
"ship_id": "660e8400-e29b-41d4-a716-446655440001",
"rule": {
"rule_id": "bb0e8400-e29b-41d4-a716-446655440010",
"name": "Circularize",
"enabled": true,
"mode": "once",
"priority": 50,
"trigger": {"conditions": [{"field": "eccentricity", "op": ">", "value": 0.01}], "logic": "AND"},
"actions": [{"action": "orbit_match", "ref_body": "Earth", "target_eccentricity": 0.0}],
"created_at": "2025-01-15T10:30:00+00:00"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "automation_created" |
| ship_id | uuid | yes | Ship the rule belongs to |
| rule | object | yes | Full rule object (see rule fields below) |
Rule fields: rule_id (uuid), name (string), enabled (bool), mode ("once" or "continuous"), priority (int 0–99), trigger (object), actions (array), created_at (ISO 8601).
Trigger: Client sends automation_create. Only sent to requesting client.
automation_updated
Confirmation that an automation rule was updated.
{
"type": "automation_updated",
"ship_id": "660e8400-e29b-41d4-a716-446655440001",
"rule": {
"rule_id": "bb0e8400-e29b-41d4-a716-446655440010",
"name": "Circularize (updated)",
"enabled": true,
"mode": "continuous",
"priority": 50,
"trigger": {"conditions": [{"field": "eccentricity", "op": ">", "value": 0.01}], "logic": "AND"},
"actions": [{"action": "orbit_match", "ref_body": "Earth", "target_eccentricity": 0.0}],
"created_at": "2025-01-15T10:30:00+00:00"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "automation_updated" |
| ship_id | uuid | yes | Ship the rule belongs to |
| rule | object | yes | Full rule object with updated fields |
Trigger: Client sends automation_update. Only sent to requesting client.
automation_deleted
Confirmation that an automation rule was deleted.
{
"type": "automation_deleted",
"ship_id": "660e8400-e29b-41d4-a716-446655440001",
"rule_id": "bb0e8400-e29b-41d4-a716-446655440010"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "automation_deleted" |
| ship_id | uuid | yes | Ship the rule belonged to |
| rule_id | uuid | yes | Deleted rule’s identifier |
Trigger: Client sends automation_delete. Only sent to requesting client.
automation_rules
List of all automation rules for a ship, sorted by priority.
{
"type": "automation_rules",
"ship_id": "660e8400-e29b-41d4-a716-446655440001",
"rules": [
{
"rule_id": "bb0e8400-e29b-41d4-a716-446655440010",
"name": "Circularize",
"enabled": true,
"mode": "once",
"priority": 50,
"trigger": {"conditions": [], "logic": "AND"},
"actions": [],
"created_at": "2025-01-15T10:30:00+00:00"
}
]
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "automation_rules" |
| ship_id | uuid | yes | Ship the rules belong to |
| rules | array | yes | Array of rule objects, sorted by priority ascending |
Trigger: Client sends automation_list. Only sent to requesting client.
automation_triggered
Notification that an automation rule fired during tick processing.
{
"type": "automation_triggered",
"rule_id": "bb0e8400-e29b-41d4-a716-446655440010",
"rule_name": "Circularize",
"tick": 123456,
"actions_executed": [{"action": "orbit_match", "ref_body": "Earth"}],
"ship_id": "660e8400-e29b-41d4-a716-446655440001",
"ship_name": "Horse"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "automation_triggered" |
| rule_id | uuid | yes | Rule that fired |
| rule_name | string | yes | Rule display name |
| tick | integer | yes | Tick when the rule fired |
| actions_executed | array | yes | Actions that were executed |
| ship_id | uuid | yes | Ship the rule belongs to |
| ship_name | string | yes | Ship display name |
Trigger: tick-engine publishes automation.triggered event on the galaxy:events Redis stream; api-gateway forwards to the owning player (looked up via Redis ship:{id} → player_id). Only sent to the ship’s owner, not broadcast.
maneuver_status
Current maneuver state for the player’s ship.
{
"type": "maneuver_status",
"active": true,
"maneuver": {
"type": "rendezvous",
"ref_body": "Earth",
"rule_name": "Dock at Station",
"started_tick": 100,
"started_game_time": "2025-01-15T10:30:00+00:00",
"phase": "plane_change",
"sub_phase": "burning",
"target_inclination": 0.5,
"target_id": "990e8400-e29b-41d4-a716-446655440004",
"target_type": "station",
"dock_on_arrival": true,
"status_text": "Plane change: 2.3° remaining",
"element_errors": {"inclination": 0.04, "raan": 0.01},
"burn_eta_seconds": 120,
"paused": false,
"brach_sub_phase": null,
"brach_coast_ratio": null,
"brach_burn_time": null,
"brach_coast_time": null,
"brach_total_time": null,
"brach_elapsed": null,
"hold_distance_m": null
}
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "maneuver_status" |
| active | boolean | yes | Whether a maneuver is running |
| maneuver | object|null | yes | Maneuver details (null if active is false) |
Maneuver object fields:
| Field | Type | Description |
|---|---|---|
| type | string | Maneuver type (e.g., "orbit_match", "rendezvous", "station_keep") |
| ref_body | string | Reference body name |
| rule_name | string | Automation rule that started the maneuver |
| started_tick | integer | Tick when maneuver began |
| started_game_time | string|null | ISO 8601 game time when started |
| phase | string|null | Current maneuver phase |
| sub_phase | string|null | Current sub-phase |
| target_inclination | number|null | Target inclination (radians) |
| target_id | uuid|null | Target entity ID |
| target_type | string|null | Target entity type |
| dock_on_arrival | boolean|null | Whether to dock when close enough |
| status_text | string|null | Human-readable status |
| element_errors | object|null | Orbital element error magnitudes |
| burn_eta_seconds | integer|null | Estimated burn time remaining |
| paused | boolean | Whether the maneuver is paused |
| brach_sub_phase | string|null | Brachistochrone sub-phase |
| brach_coast_ratio | number|null | Brachistochrone coast ratio |
| brach_burn_time | number|null | Brachistochrone burn time (seconds) |
| brach_coast_time | number|null | Brachistochrone coast time (seconds) |
| brach_total_time | number|null | Brachistochrone total time (seconds) |
| brach_elapsed | number|null | Brachistochrone elapsed time (seconds) |
| hold_distance_m | number|null | Station-keeping hold distance (meters) |
Trigger: Client sends maneuver_query. Only sent to requesting client.
maneuver_aborted
Confirmation that a maneuver was aborted.
{
"type": "maneuver_aborted",
"maneuver_type": "rendezvous"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "maneuver_aborted" |
| maneuver_type | string | yes | Type of maneuver that was aborted |
Trigger: Client sends maneuver_abort while a maneuver is active. Errors: E028 if no active maneuver.
maneuver_paused
Confirmation that a maneuver was paused.
{
"type": "maneuver_paused",
"maneuver_type": "orbit_match"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "maneuver_paused" |
| maneuver_type | string | yes | Type of maneuver that was paused |
Trigger: Client sends maneuver_pause. Errors: E028 if no active maneuver, E029 if already paused.
maneuver_resumed
Confirmation that a paused maneuver was resumed.
{
"type": "maneuver_resumed",
"maneuver_type": "orbit_match"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "maneuver_resumed" |
| maneuver_type | string | yes | Type of maneuver that was resumed |
Trigger: Client sends maneuver_resume. Errors: E028 if no active maneuver, E030 if not paused.
ship_switched
Confirmation that the player’s active ship was switched.
{
"type": "ship_switched",
"ship_id": "660e8400-e29b-41d4-a716-446655440002"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "ship_switched" |
| ship_id | uuid | yes | New active ship ID |
Trigger: Client sends switch_ship and the players service confirms the switch. Only sent to requesting client. Subsequent state broadcasts reflect the new active ship.
fleet_list
List of all ships owned by the player, enriched with live Redis state.
{
"type": "fleet_list",
"ships": [
{
"ship_id": "660e8400-e29b-41d4-a716-446655440001",
"name": "Horse",
"ship_class": "fast_frigate",
"fuel": 10000.0,
"fuel_capacity": 10000.0,
"thrust_level": 0.0,
"docked_station_id": "",
"position": {"x": 1.496e11, "y": 0.0, "z": 6771000.0},
"rule_count": 2
}
],
"active_ship_id": "660e8400-e29b-41d4-a716-446655440001"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "fleet_list" |
| ships | array | yes | Array of ship objects with live state |
| active_ship_id | uuid | yes | Currently active ship ID |
Ship object fields: ship_id, name, ship_class, fuel, fuel_capacity, thrust_level, docked_station_id, position (vec3, if available), rule_count (number of automation rules), system_id (star system, default "sol").
Trigger: Client sends fleet_list. Only sent to requesting client.
fleet_spawned
Confirmation that a new fleet ship was spawned.
{
"type": "fleet_spawned",
"ship_id": "660e8400-e29b-41d4-a716-446655440002",
"name": "Scout",
"ship_class": "fast_frigate"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "fleet_spawned" |
| ship_id | uuid | yes | New ship’s identifier |
| name | string | yes | Ship display name |
| ship_class | string | yes | Ship class type |
Trigger: Client sends fleet_spawn and the players service successfully creates the ship. Only sent to requesting client.
fleet_abandoned
Confirmation that a fleet ship was abandoned.
{
"type": "fleet_abandoned",
"ship_id": "660e8400-e29b-41d4-a716-446655440002",
"active_ship_id": "660e8400-e29b-41d4-a716-446655440001"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "fleet_abandoned" |
| ship_id | uuid | yes | Abandoned ship’s identifier |
| active_ship_id | uuid | yes | Current active ship (may change if the abandoned ship was active) |
Trigger: Client sends fleet_abandon and the players service confirms. Only sent to requesting client.
fleet_command_result
Result of a fleet-wide command issued to all non-active ships.
{
"type": "fleet_command_result",
"command": "follow_me",
"ships_affected": 3
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "fleet_command_result" |
| command | string | yes | Command that was executed ("follow_me" or "hold_position") |
| ships_affected | integer | yes | Number of fleet ships that received the command |
Trigger: Client sends fleet_command. follow_me creates station-keeping automation rules on each fleet ship targeting the active ship. hold_position deletes all automation rules and sets thrust to 0. Only sent to requesting client.
galaxy:systems
List of all star systems in the galaxy.
{
"type": "galaxy:systems",
"systems": [
{
"id": "sol",
"name": "Sol",
"star_body": "Sun",
"position": {"x": 0.0, "y": 0.0, "z": 0.0}
}
]
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "galaxy:systems" |
| systems | array | yes | Array of star system objects |
| systems[].id | string | yes | System identifier (e.g., "sol") |
| systems[].name | string | yes | Display name (e.g., "Sol") |
| systems[].star_body | string | yes | Name of the primary star body |
| systems[].position | vec3 | yes | Galactic position in meters |
Trigger: Client sends request_systems. Only sent to requesting client.
automation_copied
Confirmation that automation rules were copied between ships.
{
"type": "automation_copied",
"source_ship_id": "550e8400-e29b-41d4-a716-446655440000",
"target_ship_id": "660e8400-e29b-41d4-a716-446655440001",
"rules": [
{
"rule_id": "new-uuid",
"name": "Rule name",
"enabled": false,
"mode": "once",
"priority": 50,
"trigger": {},
"actions": [],
"created_at": "2025-01-15T10:30:00+00:00"
}
],
"count": 1
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "automation_copied" |
| source_ship_id | uuid | yes | Ship rules were copied from |
| target_ship_id | uuid | yes | Ship rules were copied to |
| rules | array | yes | Array of newly created rule objects on target ship |
| count | integer | yes | Number of rules copied |
Notes: Copied rules are always created with enabled: false to prevent unintended activation. Name conflicts are resolved by appending " (N)" suffix. Only sent to requesting client.
system_snapshot
Full snapshot of all entities in a star system, returned in response to request_system_snapshot.
{
"type": "system_snapshot",
"system_id": "sol",
"bodies": [...],
"ships": [...],
"stations": [...],
"jumpgates": [...]
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "system_snapshot" |
| system_id | string | yes | Requested system identifier |
| bodies | array | yes | All celestial bodies in the system (same format as state message bodies) |
| ships | array | yes | All ships in the system (same format as state message ships) |
| stations | array | yes | All stations in the system (same format as state message stations) |
| jumpgates | array | yes | All jump gates in the system |
Trigger: Client sends request_system_snapshot. Only sent to requesting client.
ship_log_history
Ship event log entries returned in response to ship_log_request.
{
"type": "ship_log_history",
"ship_id": "550e8400-e29b-41d4-a716-446655440000",
"entries": [
{
"id": 42,
"ship_id": "550e8400-e29b-41d4-a716-446655440000",
"event_type": "spawn",
"game_time": "2025-01-15T10:30:00+00:00",
"tick": 12345,
"message": "Ship spawned at ISS"
}
],
"has_more": true
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "ship_log_history" |
| ship_id | uuid | yes | Ship whose log was requested |
| entries | array | yes | Array of log entry objects |
| entries[].id | integer | yes | Log entry ID (for pagination cursor) |
| entries[].ship_id | uuid | yes | Ship ID |
| entries[].event_type | string | yes | Event type (e.g., "spawn", "dock", "crash") |
| entries[].game_time | string | yes | Game time when event occurred (ISO 8601) |
| entries[].tick | integer | yes | Tick number when event occurred |
| entries[].message | string | yes | Human-readable event description |
| has_more | boolean | yes | True if more entries exist before before_id cursor |
Trigger: Client sends ship_log_request. Only sent to requesting client.
Client → Server Messages
auth
First message after WebSocket open. Must arrive within 5 seconds.
{
"type": "auth",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "auth" |
| token | string | yes | JWT token from login or registration |
control
Combined rotation and thrust input.
{
"type": "control",
"rotation": {"x": 1.0, "y": 0.0, "z": 0.0},
"thrust_level": 0.5
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "control" |
| rotation | vec3 | no | Rotation input per axis: pitch (x), yaw (y), roll (z). Each -1.0 to 1.0 |
| thrust_level | number | no | Throttle level, 0.0 to 1.0 |
Both fields are optional; omit either to leave it unchanged. Either or both can be sent in a single message.
Rate limit: 60 messages per second per connection. Within each tick, multiple commands are coalesced — only the most recent value is applied when physics processes.
Rotation input mapping:
| Index | Name | Key (+1) | Key (-1) | Effect of +1 |
|---|---|---|---|---|
| x | Pitch | W | S | Nose up |
| y | Yaw | A | D | Nose left |
| z | Roll | Q | E | Roll left (port wing drops) |
Thrust behavior:
- Thrust level persists until changed (set-and-forget)
- Incremented/decremented by 0.1 per key press (+/- keys)
- Clamped to 0.0–1.0
service
Request a game service.
{
"type": "service",
"service": "fuel",
"body": "Earth"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "service" |
| service | string | yes | Service name: "fuel" or "reset" |
| body | string | no | Target body for reset service (omit for fuel or Earth-default reset) |
Rate limit: 1 request per minute per connection.
Response behavior:
- On success: no response sent (fire-and-forget)
- On error: server sends
errormessage (E003, E004, E006, E007, E008) - Effect visible in next
statemessage (fuel refilled, position reset)
attitude_hold
Enable or disable attitude hold (rate damping).
{
"type": "attitude_hold",
"enabled": true
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "attitude_hold" |
| enabled | boolean | yes | true to enable hold mode, false to disable all attitude control |
attitude_prograde
Orient ship nose toward velocity vector. No payload.
{
"type": "attitude_prograde"
}
attitude_retrograde
Orient ship nose opposite velocity vector. No payload.
{
"type": "attitude_retrograde"
}
attitude_normal
Orient ship nose along orbit normal (r × v). No payload.
{
"type": "attitude_normal"
}
attitude_antinormal
Orient ship nose opposite orbit normal. No payload.
{
"type": "attitude_antinormal"
}
attitude_radial
Orient ship nose perpendicular to velocity in orbital plane. No payload.
{
"type": "attitude_radial"
}
attitude_antiradial
Orient ship nose opposite radial direction. No payload.
{
"type": "attitude_antiradial"
}
attitude_local_horizontal
Orient ship nose along horizontal velocity component. No payload.
{
"type": "attitude_local_horizontal"
}
attitude_local_vertical
Orient ship nose along radial direction from body center. No payload.
{
"type": "attitude_local_vertical"
}
attitude_target
Orient ship nose toward a selected target entity. Requires target ID and type.
{
"type": "attitude_target",
"target_id": "Earth",
"target_type": "body"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "attitude_target" |
| target_id | string | yes | Target entity ID (body name, ship_id, station_id) |
| target_type | string | yes | "body", "ship", "station" |
Gateway routes to physics SetAttitudeMode with ATTITUDE_TARGET mode and target fields. On failure (E022 target not found, E024 too close), sends error message to client.
attitude_anti_target
Orient ship nose away from a selected target entity. Requires target ID and type.
{
"type": "attitude_anti_target",
"target_id": "Earth",
"target_type": "body"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "attitude_anti_target" |
| target_id | string | yes | Target entity ID (body name, ship_id, station_id) |
| target_type | string | yes | "body", "ship", "station" |
Gateway routes to physics SetAttitudeMode with ATTITUDE_ANTI_TARGET mode and target fields. On failure (E022 target not found, E024 too close), sends error message to client.
attitude_target_prograde
Orient ship nose along the relative velocity vector with respect to a selected target entity. Requires target ID and type.
{
"type": "attitude_target_prograde",
"target_id": "Earth",
"target_type": "body"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "attitude_target_prograde" |
| target_id | string | yes | Target entity ID (body name, ship_id, station_id) |
| target_type | string | yes | "body", "ship", "station" |
Gateway routes to physics SetAttitudeMode with ATTITUDE_TARGET_PROGRADE mode and target fields. On failure (E022 target not found, E023 relative velocity too low), sends error message to client.
attitude_target_retrograde
Orient ship nose opposite to the relative velocity vector with respect to a selected target entity. Requires target ID and type.
{
"type": "attitude_target_retrograde",
"target_id": "Earth",
"target_type": "body"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "attitude_target_retrograde" |
| target_id | string | yes | Target entity ID (body name, ship_id, station_id) |
| target_type | string | yes | "body", "ship", "station" |
Gateway routes to physics SetAttitudeMode with ATTITUDE_TARGET_RETROGRADE mode and target fields. On failure (E022 target not found, E023 relative velocity too low), sends error message to client.
chat_send
Send a chat message to the global channel.
{
"type": "chat_send",
"message": "Hello everyone!"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "chat_send" |
| message | string | yes | Message text (1–256 characters after trimming) |
Validation:
- Message must be a string
- After
.trim(), must be non-empty (error E020) - After
.trim(), must be ≤ 256 characters (error E019)
Rate limit: 5 messages per second per player (error E018).
On success: Server broadcasts chat_message to all connected clients (including sender). No explicit acknowledgment is sent.
target_select
Inform the server of the player’s current target selection. Used to relay targeting information to other players (ship-to-ship only).
{
"type": "target_select",
"target_id": "660e8400-e29b-41d4-a716-446655440001",
"target_type": "ship"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "target_select" |
| target_id | string|null | yes | Target entity ID (body name, ship_id, station_id, lagrange key), or null to deselect |
| target_type | string|null | yes | "body", "ship", "station", "lagrange", or null to deselect |
Server behavior:
- Stores targeting state in-memory:
player_id → (target_id, target_type) - Only ship-to-ship targeting triggers notification: if the old or new target is a ship, the server sends
targeted_byto the affected ship’s owner - Non-ship targets (bodies, stations, lagrange points) are stored but do not trigger notifications (these aren’t players)
- On disconnect, the player’s targeting entry is removed and any targeted ship is notified
No response sent — fire-and-forget. Effect is visible via targeted_by messages to other players.
Multi-target note: The client maintains an N-target set locally (state.targets Map). Only the focused target is sent to the server via target_select. The multi-target set is entirely client-local state — no protocol format change is needed.
ship_rename
Rename the player’s ship. The name is stored in the ship:{id} Redis hash and included in all subsequent state broadcasts to all players.
{
"type": "ship_rename",
"name": "My Ship"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "ship_rename" |
| name | string | yes | New ship name (3-32 characters after trimming whitespace) |
Validation:
- Name is trimmed of leading/trailing whitespace
- After trimming, must be 3-32 characters (error E030 if invalid)
On success: Server updates ship:{id} Redis hash name field and sends ship_renamed confirmation to the requesting client. The new name is visible to all players in subsequent state broadcasts.
Default name: Ships are created with the player’s username as the default name (set during spawn).
ship_renamed
Server response confirming a successful ship rename.
{
"type": "ship_renamed",
"name": "My Ship"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "ship_renamed" |
| name | string | yes | The new ship name (as stored) |
Trigger: Client sends a valid ship_rename message. Only sent to the requesting client (not broadcast).
ping
Heartbeat / latency measurement.
{
"type": "ping",
"timestamp": 1705315800000
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "ping" |
| timestamp | integer | yes | Client timestamp (Unix milliseconds via Date.now()) |
Sent every 30 seconds. Server echoes as pong with the same timestamp.
automation_create
Create a new automation rule for a ship.
{
"type": "automation_create",
"name": "Circularize",
"mode": "once",
"priority": 50,
"trigger": {
"conditions": [{"field": "eccentricity", "op": ">", "value": 0.01}],
"logic": "AND"
},
"actions": [{"action": "orbit_match", "ref_body": "Earth", "target_eccentricity": 0.0}],
"ship_id": "660e8400-e29b-41d4-a716-446655440001"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "automation_create" |
| name | string | yes | Rule display name (1–64 characters) |
| mode | string | no | "once" (default) or "continuous" |
| priority | integer | no | 0–99, default 50 (lower = higher priority) |
| trigger | object | yes | Trigger conditions |
| actions | array | yes | Actions to execute |
| ship_id | uuid | no | Target ship (defaults to active ship; must be owned) |
Validation errors: E026 for invalid fields, E025 if rule limit reached (max 10 per ship), E034 if ship not owned.
On success: Server responds with automation_created.
automation_update
Update an existing automation rule.
{
"type": "automation_update",
"rule_id": "bb0e8400-e29b-41d4-a716-446655440010",
"name": "Circularize (updated)",
"enabled": false,
"ship_id": "660e8400-e29b-41d4-a716-446655440001"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "automation_update" |
| rule_id | uuid | yes | Rule to update |
| name | string | no | New name (1–64 characters) |
| enabled | boolean | no | Enable/disable the rule |
| mode | string | no | "once" or "continuous" |
| priority | integer | no | 0–99 |
| trigger | object | no | New trigger conditions |
| actions | array | no | New actions |
| ship_id | uuid | no | Target ship (defaults to active ship; must be owned) |
Only provided fields are updated; omitted fields remain unchanged.
Validation errors: E027 if rule not found, E026 for invalid fields, E034 if ship not owned.
On success: Server responds with automation_updated.
automation_delete
Delete an automation rule.
{
"type": "automation_delete",
"rule_id": "bb0e8400-e29b-41d4-a716-446655440010",
"ship_id": "660e8400-e29b-41d4-a716-446655440001"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "automation_delete" |
| rule_id | uuid | yes | Rule to delete |
| ship_id | uuid | no | Target ship (defaults to active ship; must be owned) |
Validation errors: E027 if rule not found or rule_id missing, E034 if ship not owned.
On success: Server responds with automation_deleted.
automation_list
Request the list of all automation rules for a ship.
{
"type": "automation_list",
"ship_id": "660e8400-e29b-41d4-a716-446655440001"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "automation_list" |
| ship_id | uuid | no | Target ship (defaults to active ship; must be owned) |
Validation errors: E034 if ship not owned.
On success: Server responds with automation_rules.
automation_copy
Copy automation rules from one ship to another. Both ships must be owned by the player. Copied rules are created disabled with unique names (conflict resolution appends " (N)" suffix).
{
"type": "automation_copy",
"source_ship_id": "550e8400-e29b-41d4-a716-446655440000",
"target_ship_id": "660e8400-e29b-41d4-a716-446655440001",
"rule_ids": ["rule-uuid-1", "rule-uuid-2"]
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "automation_copy" |
| source_ship_id | uuid | yes | Ship to copy rules from |
| target_ship_id | uuid | yes | Ship to copy rules to (must differ from source) |
| rule_ids | array | no | Specific rule IDs to copy; if omitted, copies all rules |
Validation errors: E026 if source/target missing or identical, E034 if either ship not owned, E026 if no valid rules found.
On success: Server responds with automation_copied.
ship_log_request
Request event log entries for a ship. Supports cursor-based pagination.
{
"type": "ship_log_request",
"ship_id": "550e8400-e29b-41d4-a716-446655440000",
"limit": 50,
"before_id": 100
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "ship_log_request" |
| ship_id | uuid | no | Ship to query (defaults to active ship) |
| limit | integer | no | Max entries to return (default: 50) |
| before_id | integer | no | Return entries with ID less than this value (pagination cursor) |
On success: Server responds with ship_log_history.
request_system_snapshot
Request a full snapshot of all entities in a star system. Used when switching views to a different system (e.g., galaxy view → system view).
{
"type": "request_system_snapshot",
"system_id": "alpha_centauri"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "request_system_snapshot" |
| system_id | string | yes | System to fetch entities for |
Validation errors: E040 if system_id is missing.
On success: Server responds with system_snapshot.
switch_system
Request to move the player’s ship to a different star system. Not yet implemented — returns NOT_IMPLEMENTED error (see #736).
{
"type": "switch_system",
"system_id": "alpha_centauri"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "switch_system" |
| system_id | string | yes | Target system to travel to |
Status: Placeholder — inter-system travel will be implemented via jump gates.
maneuver_query
Query the current maneuver state for the player’s active ship.
{
"type": "maneuver_query"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "maneuver_query" |
On success: Server responds with maneuver_status (with active: true and maneuver details, or active: false and maneuver: null).
maneuver_abort
Abort the active maneuver on the player’s active ship.
{
"type": "maneuver_abort"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "maneuver_abort" |
Sets the abort flag on the maneuver hash in Redis. The tick-engine reads this flag and stops the maneuver on the next tick.
On success: Server responds with maneuver_aborted. Errors: E028 if no active maneuver.
maneuver_pause
Pause the active maneuver on the player’s active ship.
{
"type": "maneuver_pause"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "maneuver_pause" |
On success: Server responds with maneuver_paused. Errors: E028 if no active maneuver, E029 if already paused.
maneuver_resume
Resume a paused maneuver on the player’s active ship.
{
"type": "maneuver_resume"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "maneuver_resume" |
On success: Server responds with maneuver_resumed. Errors: E028 if no active maneuver, E030 if not paused.
switch_ship
Switch the player’s active ship to a different ship in their fleet.
{
"type": "switch_ship",
"ship_id": "660e8400-e29b-41d4-a716-446655440002"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "switch_ship" |
| ship_id | uuid | yes | Ship to switch to (must be owned by the player) |
On success: Server responds with ship_switched. Subsequent state broadcasts use the new ship as the player’s own ship. Errors: E033 if ship_id is empty.
fleet_list
Request a list of all ships owned by the player, with live state from Redis.
{
"type": "fleet_list"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "fleet_list" |
On success: Server responds with fleet_list (server → client).
fleet_spawn
Spawn a new ship for the player’s fleet.
{
"type": "fleet_spawn",
"name": "Scout",
"ship_class": "fast_frigate",
"station_id": "990e8400-e29b-41d4-a716-446655440004",
"system_id": "sol"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "fleet_spawn" |
| name | string | no | Ship display name (defaults to empty) |
| ship_class | string | no | Ship class (default "fast_frigate") |
| station_id | uuid | no | Station to spawn at (defaults to empty) |
| system_id | string | no | Star system to spawn in (default "sol") |
On success: Server responds with fleet_spawned. Errors: E031 if ship limit reached, E032 if invalid ship name.
fleet_abandon
Abandon a ship from the player’s fleet, removing it from the game.
{
"type": "fleet_abandon",
"ship_id": "660e8400-e29b-41d4-a716-446655440002"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "fleet_abandon" |
| ship_id | uuid | yes | Ship to abandon (must be owned by the player) |
If the abandoned ship was the active ship, the server automatically switches to another ship in the fleet. Cannot abandon the last ship.
On success: Server responds with fleet_abandoned. Errors: E033 if ship_id is empty, E034 if ship not owned or last ship.
fleet_command
Issue a command to all fleet ships (excluding the active ship).
{
"type": "fleet_command",
"command": "follow_me"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "fleet_command" |
| command | string | yes | "follow_me" or "hold_position" |
Commands:
follow_me— Deletes existing automation rules on each fleet ship and creates astation_keeprule targeting the active shiphold_position— Deletes all automation rules on each fleet ship and sets thrust to 0
On success: Server responds with fleet_command_result. Errors: E035 if command is not recognized.
request_systems
Request the list of all star systems in the galaxy.
{
"type": "request_systems"
}
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | yes | "request_systems" |
On success: Server responds with galaxy:systems.
Data Types
| Type | Format | Example |
|---|---|---|
| vec3 | {"x": number, "y": number, "z": number} |
{"x": 1000.0, "y": 2000.0, "z": 3000.0} |
| quaternion | {"w": number, "x": number, "y": number, "z": number} |
{"w": 1.0, "x": 0.0, "y": 0.0, "z": 0.0} |
| uuid | string | "550e8400-e29b-41d4-a716-446655440000" |
| timestamp | ISO 8601 string or Unix milliseconds integer | "2025-01-15T10:30:00Z" or 1705315800000 |
Units
All values use SI units unless otherwise noted.
| Property | Unit | Notes |
|---|---|---|
| position | meters | vec3, ICRF coordinates |
| velocity | m/s | vec3, ICRF coordinates |
| angular_velocity | rad/s | vec3, body frame |
| mass | kg | |
| fuel | kg | |
| fuel_capacity | kg | |
| radius | meters | |
| rotation_period | seconds | Negative = retrograde rotation |
| axial_tilt | degrees | |
| prime_meridian_at_epoch | degrees | |
| inertia_tensor | kg·m² | Diagonal elements only |
| thrust_level | dimensionless | 0.0 to 1.0 |
| rotation input | dimensionless | -1.0 to 1.0 per axis |
| wheel_saturation | dimensionless | 0.0 to 1.0 per axis (0 = empty, 1 = saturated) |
Derived and Computed Fields
Some fields in state messages are computed rather than stored directly:
| Field | Computed By | Source |
|---|---|---|
| fuel_capacity | physics | Ship spec constant |
| mass | physics | dry_mass + current fuel |
| wheel_saturation | api-gateway | abs(wheel_momentum[axis]) / 10000 |
Rate Limiting
| Message Type | Limit | Rationale |
|---|---|---|
control |
60/sec | Continuous control inputs (60 Hz exceeds typical human input rates) |
service |
1/min | Prevent abuse of fuel/reset |
chat_send |
5/sec | Prevent chat spam |
Per-tick coalescing means the effective update rate is bounded by tick rate regardless of input frequency.
Error Codes
| Code | Message | Description |
|---|---|---|
| E001 | Invalid rotation value | Rotation not a dict, or axis value non-numeric, non-finite, or outside -1 to 1 |
| E002 | Invalid thrust value | Thrust value non-numeric, non-finite, or outside 0 to 1 |
| E003 | Invalid service type | Unknown service requested |
| E004 | Rate limit exceeded | Too many requests |
| E005 | Authentication failed | Invalid or expired token |
| E006 | Ship not found | Player’s ship does not exist |
| E007 | Service unavailable | Requested service temporarily unavailable |
| E008 | Server error | Internal server error |
| E009 | Player not found | Player account does not exist |
| E010 | Username taken | Username already registered |
| E011 | Invalid username | Username format invalid (3-20 chars, alphanumeric + underscore) |
| E012 | Invalid password | Password too short (minimum 8 characters) |
| E013 | Server full | Maximum concurrent connections reached (16) |
| E014 | Account deleted | Account deleted by administrator |
| E015 | Invalid tick rate | Tick rate outside valid range (0.1-100.0 Hz) |
| E016 | Snapshot not found | Requested snapshot does not exist |
| E017 | Not initialized | Physics service not yet initialized (startup race) |
| E018 | Chat rate limited | Too many chat messages (max 5/sec) |
| E019 | Chat message too long | Message exceeds 256 characters |
| E020 | Chat message empty | Message is empty after trimming |
| E022 | Target not found | Target entity does not exist |
| E023 | Relative velocity too low | Relative velocity too low for target prograde/retrograde |
| E024 | Too close to target | Too close to target for target/anti-target mode |
| E025 | Rule limit reached | Maximum 10 automation rules per ship |
| E026 | Invalid automation rule | Automation rule validation failed (invalid field, operator, action, etc.) |
| E027 | Rule not found | Automation rule does not exist or belongs to different ship |
| E028 | No active maneuver | No maneuver is currently running for this ship |
| E029 | Invalid time scale | Time scale outside valid range (0.1-100.0) |
| E030 | Invalid ship_id | Ship ID is empty or malformed |
| E031 | Ship is not docked / Ship limit reached | Docking: undock attempt when not docked. Fleet: player exceeded maximum ships per player |
| E032 | Too far from station / Invalid ship name | Docking: ship > 500 m from station. Fleet: ship name not 3-32 characters, or duplicate name for same player |
| E033 | Relative velocity too high / Ship not found | Docking: relative velocity > 10 m/s. Fleet: ship does not exist or not owned by player |
| E034 | Station not found / Ship not owned | Docking: station entity missing. Fleet: ship not owned by player, or cannot abandon last ship |
| E035 | Unknown fleet command | Fleet command not recognized (valid: follow_me, hold_position) |
gRPC Failure Error Responses
When gRPC calls to the physics service fail while processing WebSocket commands (control, service, attitude_hold, and all attitude_* mode commands), the server sends an error message to the client with the following mapping:
| gRPC Status | Error Code | Message |
|---|---|---|
| NOT_FOUND | E006 | Ship not found |
| UNAVAILABLE | E007 | Service unavailable |
| DEADLINE_EXCEEDED | E007 | Service unavailable |
| Any other failure | E008 | Server error |
Additionally, the service command sends E003 if the service field is not "fuel" or "reset".
Message Size Enforcement
The server enforces a maximum WebSocket message size of 64 KB (65536 bytes). Messages exceeding this limit are rejected before JSON parsing to prevent memory exhaustion attacks.
Implementation: In the WebSocket message loop, read raw bytes first using receive_bytes(), check len(raw) > 65536, and close the connection with code 1009 (Message Too Big) if exceeded. Only parse JSON after the size check passes. This prevents an attacker from sending arbitrarily large payloads to exhaust server memory.
Invalid Message Handling
| Condition | Response | Disconnect? |
|---|---|---|
| Malformed JSON | Send error E008 | No |
| Unknown message type | Ignore silently | No |
| Valid type, invalid payload | Send error (E001-E003) | No |
| Message too large (> 64 KB) | Close connection (code 1009) | Yes |
| Too many errors (> 10 in 60s) | Send E004, close connection | Yes |
Unknown message types are silently ignored for forward compatibility (new message types don’t break old clients).
Reconnection
When the WebSocket connection closes (server restart, network interruption, ping timeout), the client reconnects automatically using exponential backoff with jitter.
| Parameter | Value |
|---|---|
| Initial delay | 1 second |
| Multiplier | 2× per attempt |
| Maximum delay | 30 seconds |
| Jitter | ×[0.8, 1.2] of computed delay |
| Reset | On successful connection (onopen) |
Backoff sequence (before jitter): 1s, 2s, 4s, 8s, 16s, 30s, 30s, …
Jitter prevents thundering herd when the server restarts and multiple clients reconnect simultaneously.
Client-Side Message Queue
When the WebSocket is not in the OPEN state (during reconnection), outgoing messages are queued in-memory and flushed in order after re-authentication succeeds on reconnect.
| Parameter | Value |
|---|---|
| Max queue size | 100 messages |
| Overflow behavior | Oldest message dropped (FIFO shift) |
| Excluded types | ping (stale by definition) |
| Flush trigger | After auth message sent in onopen handler |
At 60 Hz control input rate, the 100-message cap covers approximately 1.6 seconds of disconnect. Longer disconnects naturally shed stale control data (sent most frequently) while preserving more recent attitude and service commands (sent less frequently, remain near the tail of the queue). Server-side per-tick coalescing handles any duplicate control messages that arrive in the flush burst.
Server-Side Reconnection Safety
When a player reconnects, the new WebSocket may be accepted before the old connection’s cleanup (finally block) runs. The server guards against this race condition: remove_connection(player_id, websocket) only removes the connection entry if the stored WebSocket matches the one being cleaned up. This prevents a stale cleanup from removing the player’s new active connection.
Disconnection Cleanup
When a player disconnects (WebSocket close), the server must clean up connection-scoped state only. Persistent game state (maneuvers, automation rules) must NOT be cleaned up on disconnect — they are long-running operations that survive reconnects.
| State | Key Pattern | Cleanup Action |
|---|---|---|
| Online status | connections:online |
SREM player_id |
| Targeting state | In-memory map | Remove entry, notify affected ships |
NOT cleaned up on disconnect (persistent game state):
maneuver:{ship_id}— managed by tick-engine, cleaned up on completion/cancellation/ship deletionautomation:{ship_id}:*— managed by player, cleaned up on explicit deletion or ship deletion
Disconnection Behavior
- Game world continues regardless of player connection status
- Ship maintains current trajectory and thrust settings while disconnected
- On reconnect, client receives current state and resumes control