The design divides the simulation into two layers with a clear contract between them:
Clients never send authoritative state. They send intents — “what I am trying to do” — and the server determines what actually happens, including any random element.
Two distinct spatial concepts govern how the simulation is organized and deployed. These terms have precise meanings and should not be used interchangeably.
A Zone is a named orbital region within a star system — a gameplay and narrative subdivision with no server boundary between them. Examples within the solar system: Earth Orbit, Mars Belt, Mars Surface Orbit, Inner Belt, Outer Belt. Ships move between Zones freely; the server managing the Sector handles all Zones within it.
A Sector is a star-system-scale region managed by exactly one server instance. The entire solar system is one Sector. Alpha Centauri, when the simulation expands there, will be another Sector on another server instance. All Zones within a Sector are the responsibility of that Sector’s server; there is no intra-Sector server boundary.
When the simulation spans more than one Sector, a thin Galaxy Router layer connects the Sector Servers, maintaining the inter-Sector map and brokering ship handoffs at Sector boundaries. The handoff sequence when a ship crosses a Sector boundary:
The pre-arrival step is important because a ship near a boundary may be within sensor range of entities in both Sectors; the receiving server must know about the ship before it fully crosses.
Current status: A single server instance (SectorServer) manages all Zones. The Sector-scoped federation refactor — distributing theGalaxyclass across instances and building the Galaxy Router — is a future milestone planned for when the single-Sector feature set is complete.
This is the central organizing concept for how human and AI captains share the same underlying control protocol. For the narrative perspective on how captains experience command through the XO, see The Captain’s Interface. For the technical architecture of the three-layer client model, see Client Architecture.
A headless NPC client runs an AI Captain that reads the server’s snapshot for its ship, makes decisions (navigation, combat, trade), and emits a stream of control commands back to the server. The AI Captain is fully responsible for translating strategic goals into the low-level control stream.
A PC client inserts a human between the strategic layer and the control stream, but keeps the same AI machinery underneath:
Human Player
│
│ high-level orders
│ ("attack that freighter", "plot course to Centauri", "raise shields")
▼
AI Executive Officer (XO)
│
│ continuous control stream
│ (throttle curves, heading adjustments, weapon fire timing, shield toggles)
▼
[same command protocol as NPC]
│
▼
Galaxy Server
The XO’s job is to receive a player’s high-level intent and translate it into the same low-level control commands the NPC’s AI Captain would produce autonomously. From the server’s perspective, a PC captain and an NPC captain are indistinguishable — both emit CommandRequest objects with an originType of "pc" or "npc".
The practical consequence: the NPC codebase is the foundation. The PC codebase is the NPC codebase plus a human decision-making layer at the top. Adding a GUI does not require replacing the AI; it requires surfacing the AI’s state and intent to a human who can redirect it.
The XO enforces a safety constraint on all navigation commands: it prevents maneuvers that would result in collision with celestial bodies, stations, or other vessels. A navigation order that would place the ship on a collision trajectory is rejected by the XO before it reaches the server’s command pipeline. This applies equally to PC and NPC captains — the XO does not distinguish between a human order and an AI decision when evaluating collision risk.
Deliberate ramming as a tactical action is not currently supported. If introduced in a future combat system revision, it would require an explicit override command that bypasses the XO’s collision avoidance, with appropriate risk and consequence modeling.
The Character type captures this distinction:
FictionRole: pc vs. npc — narrative role.controlMode: humanGui, aiAgent, script, idle — how the captain is currently being driven.A PC captain with controlMode = humanGui has a human feeding orders to the XO. The same captain with controlMode = aiAgent runs fully autonomously — the XO becomes the captain. This makes it natural for a PC ship to run on autopilot when the player is offline.
The canonical Character struct (characters/characterTypes.d) carries:
| Category | Fields |
|---|---|
| Identity | id, name, callsign, species, gender, age, homeworld, faction |
| Narrative | fictionRole (pc/npc), controlMode, importance, status (active/missing/dead/retired), concept, centralMotivation, coreConflict, loyalty, threatLevel, tags, groups |
| Auth / admin | storyteller_id, captainId, clearanceLevel, isHeadlessOnly |
| Ship / crew | assignedVesselId, homeArtifactId, crewComplement (maxDroidSlots, currentDroidCount) |
| Skills | shipAffinity, engineeringSkill, commandSkill, evaTraining, tacticalSkill |
| Economy | credits — Credits (CR); belongs to the captain, survives ship loss |
Removed fields (v0.2): role, rank, and simulationRole were vestigial narrative metadata that carried no gameplay weight. Under the singleship model the captain’s role is implicit (they are the captain) and rank is meaningless in a solo command. These fields are silently ignored when loading legacy MongoDB documents.
CrewComplement was simplified from four fields (baseCrewSlots, baseDroidSlots, currentCrewCount, currentDroidCount) to two: maxDroidSlots and currentDroidCount. Under the singleship model there are no organic crew — only the captain (fixed in the lifepod) and crewdroids. Legacy documents with the old field names are read via fallback in fromJSON().
The galaxy is not a deterministic machine. Outcomes are shaped by two sources of randomness: the inherent unpredictability of the universe, and the varying capabilities of the captain and their crewdroids.
All dice rolls happen on the server. This is an invariant of the same character as “no negative fuel”: if clients could roll dice and report results, the anti-cheat guarantee collapses. The server is the sole source of random outcomes.
The Dice class (utilities.dice) provides the mechanism: a die of any number of sides (2–100), seeded independently per instance, with a roll returning a value in [1, sides]. Typical use is d6 (general chance), d20 (skill checks), and d100 (percentile events).
The Character sheet carries integer skill values that act as modifiers in the D&D tradition — a higher skill shifts the effective outcome of a roll:
| Stat | What it modifies |
|---|---|
commandSkill | Crewdroid coordination, morale events, fleet maneuvers, recruited droid reliability |
engineeringSkill | Repair success, crewdroid maintenance tasks, refit quality, drive charge efficiency |
shipAffinity | Ship-handling outcomes, docking, Artifact Drive engagement reliability |
evaTraining | Directing crewdroid EVA operations, boarding action rolls, dampener failure survival |
tacticalSkill | Weapon to-hit modifier in combat (see Combat section below) |
The resolution pattern is:
effective result = dice roll + stat modifier
outcome = compare effective result against difficulty threshold
Difficulty thresholds are set per command kind by the CommandHandler for that kind.
The Command type carries a metadata field specifically for dice results and modifiers. When a handler resolves a command with a roll, it records: which dice were rolled and their results, which character stats contributed and by how much, the difficulty threshold tested, and the narrative outcome category (success, partial, failure, critical). This record is included in CommandResult.details and returned to the client.
Not all randomness is tied to a command. The server’s tick loop can generate spontaneous events — equipment malfunctions, sensor anomalies, unexpected encounters, resource discoveries — by rolling against configured probability tables. These are injected as SimEvents into the event queue and arrive at clients as part of the normal snapshot stream, giving the universe a living, unpredictable quality that neither player nor AI fully controls.
Weapon fire is resolved per-tick inside step 4.5 of SimulationController.updateVessel(). Each WeaponSystem that has a target assigned and whose fire-cycle timer has elapsed fires once per tick, subject to the following sequential checks:
powerDraw × powerEfficiency units of vessel power. If power is insufficient, the shot does not fire and the cycle timer is not reset.tacticalSkill divided by 10 (integer division; baseline 50 contributes +5, max 100 contributes +10), applied only when an on-duty crewdroid is stationed at the bridge. The bridge is the ship’s tactical command center where weapon targeting systems are operated. If no crewdroid is on duty at the bridge, the skill bonus is not applied — the weapon fires with raw accuracy only. This makes crewdroid positioning a real tactical choice: pulling a droid from the bridge for EVA or damage control degrades weapon effectiveness.accuracy stat, centred on 50: cast(int)((accuracy − 50) / 10). Accuracy 50 is neutral (±0); every 10 points above or below 50 shifts the roll by ±1.Full formula (with crewdroid at bridge):
roll = d20() + tacticalSkill/10 + (accuracy − 50)/10
hit = (roll ≥ WEAPON_HIT_THRESHOLD) // threshold = 12
Full formula (no crewdroid at bridge):
roll = d20() + (accuracy − 50)/10
hit = (roll ≥ WEAPON_HIT_THRESHOLD) // threshold = 12
At baseline with crewdroid (tacticalSkill = 50, accuracy = 50) the modifier is +5, so rolls of 7 or higher hit (70% hit rate). Without crewdroid (accuracy = 50), the modifier is +0, so rolls of 12 or higher hit (45% hit rate). A highly accurate weapon (accuracy 100) with a skilled captain (tacticalSkill 100) and crewdroid at bridge adds +10 total, making any d20 roll a hit.
applyWeaponDamage(targetId, damage) is called. The damage field on WeaponSystem is in hull-fraction units [0, 1] (e.g. 0.08 = 8% per hit). If the target’s shields are raised, damage depletes shield integrity directly (shieldIntegrity −= damage) and the hull is untouched. Otherwise, damage reduces hull integrity directly (hullIntegrity −= damage). A vessel whose hull integrity reaches zero or below is queued for destruction at end-of-tick.weaponIntegrity by a small fixed amount. A weapon at zero integrity continues to fire but may malfunction in future revisions.fireCycle seconds, establishing the delay before the weapon can fire again.Each WeaponSystem carries a set of stats rolled at instantiation time from a WeaponBlueprint. The blueprint defines ranges; the factory rolls within those ranges adjusted by quality tier. The rolled stats that influence combat are:
| Stat | Role in combat |
|---|---|
damage | Points of damage applied on a hit (before shield/hull split) |
accuracy | Shifts the to-hit roll; 50 is neutral, range is 0–100 |
fireCycle | Seconds between shots; lower is faster |
powerDraw | Raw power consumed per shot |
powerEfficiency | Multiplier on powerDraw; 1.0 is nominal, lower is more efficient |
malfunctionChance | Probability of a malfunction event (future use) |
requiresSpareParts | Whether firing consumes spare parts (future use) |
weaponIntegrity | Structural health; depleted by wear per shot |
All rolled stats and current weaponIntegrity are persisted to MongoDB as part of the GameSnapshot in a dedicated weaponInstances array, separate from the systemHealth array used by other systems.
Singleships include two propulsion-related systems whose operating principles are not understood by anyone in the solar system. Both are ISystem implementations and subject to dice-modified outcomes.
The Artifact Drive produces velocities that conventional physics does not permit. It is the reason interplanetary and eventually interstellar travel is practical within a game timeframe. Key properties:
shipAffinity and current drive integrity determines whether the drive activates on a given attempt.ResourceState, separate from conventional fuel, that replenishes slowly and can be topped up at certain stations.The full lifecycle of an Artifact Drive transit is a multi-phase process. Each phase has concrete mechanics, formulas, and failure modes.
chargeRate (default 0.03/s) toward the 1.0 maximum. Charge accumulates regardless of throttle or shield state and has no power cost. Partial charge engagement: the captain does not need full charge to transit. The required charge is proportional to distance: requiredCharge = distance / MAX_RANGE where MAX_RANGE = 250. A short 50-unit hop needs only 0.20 charge (~7s), while the longest Sol route (~183 units) needs 0.73 charge (~24s). Full charge (33s) covers any destination up to 250 coordinate units.engageDrive. The server rolls a d20 with modifiers to determine whether the drive fires. On failure, the drive does not fire, charge is preserved, and a 15-second cooldown is imposed before retry."rejected" and reason "insufficient_fuel". Charge is consumed (the drive fired) but the transit does not proceed. This is a costly mistake — the captain must wait 50 seconds to recharge.requiredCharge = distance / MAX_RANGE); remaining charge is preserved for future short hops. Fuel is deducted. The vessel enters the in-transit state (see In-Transit State). The drive is marked as engaged. A SimEvent of kind "arriveZone" is enqueued at currentTime + transitTime.arriveZone event fires, the server rolls for landing accuracy. The result determines whether the vessel arrives at the intended zone, an adjacent zone, or a random zone with damage.Transit fuel cost is proportional to the Euclidean distance between origin and destination zone coordinates:
fuelCost = FUEL_COST_PER_UNIT × distance
FUEL_COST_PER_UNIT = 0.5
distance = ZoneCoords.distanceTo(origin, destination)
= sqrt((x&sub2;−x&sub1;)² + (y&sub2;−y&sub1;)² + (z&sub2;−z&sub1;)²)
The fuel check is performed after the engagement roll succeeds. If the captain does not have enough fuel, the drive fires but the transit aborts — proportional charge is consumed (the drive fired at the required level for the distance). This makes pre-flight fuel calculation important. The XO safety layer provides fuel-for-distance advisories to prevent this situation (see NPC Distance Awareness).
Transit time is proportional to distance:
transitTime = TIME_PER_UNIT × distance
TIME_PER_UNIT = 0.35 (seconds per coordinate unit)
The default Sol sector (5 zones) produces the following transit costs from common routes:
| Route | Distance | Fuel Cost | Req. Charge | Charge Time | Transit Time | Total Cycle |
|---|---|---|---|---|---|---|
| earth-orbit → mars-transit | 28.9 | 14.5 | 0.12 | 4s | 10s | 14s |
| earth-orbit → mars-orbit | 53.7 | 26.9 | 0.21 | 7s | 19s | 26s |
| earth-orbit → inner-belt | 101.1 | 50.6 | 0.40 | 13s | 35s | 48s |
| earth-orbit → outer-belt | 182.8 | 91.4 | 0.73 | 24s | 64s | 88s |
| inner-belt → outer-belt | 82.0 | 41.0 | 0.33 | 11s | 29s | 40s |
| mars-transit → mars-orbit | 25.1 | 12.6 | 0.10 | 3s | 9s | 12s |
With partial charge engagement and tuned transit speed, short hops are snappy: mars-transit is a 14-second cycle. The outer-belt round trip takes ~176s. With 500 starting fuel and a 20-unit XO reserve, operational fuel is 480 — approximately 5 round trips to outer-belt or 16 to mars-transit before refueling. Leftover charge from a short hop carries over to the next jump, further reducing subsequent wait times.
While in transit, the vessel is in a special state with the following properties:
zoneId is set to "" (empty string) to indicate transit.wearRate × dt per tick is the primary source of drive degradation. Cross-system wear from a degraded dampener also applies during transit.Transit metadata is stored on the Vessel object: transitDestination (target zone ID), transitArrivalTime (simulation time when the arriveZone event fires). These fields enable client-side progress display (ETA countdown) and server-side validation.
When a captain issues an engageDrive command, the server rolls for engagement success before any transit begins:
roll = d20() + floor(shipAffinity / 10) + floor(driveIntegrity × 5) + lowChargePenalty
lowChargePenalty = (driveCharge < 0.5) ? −2 : 0
success = (roll ≥ ENGAGEMENT_THRESHOLD)
ENGAGEMENT_THRESHOLD = 8
Low-charge penalty: When engaging the drive below 50% charge (emergency short-range jumps), the engagement roll takes a −2 penalty. This makes partial-charge escape jumps riskier but still possible for skilled captains with healthy drives.
Modifier breakdown:
| Condition | shipAffinity mod | driveIntegrity mod | Total mod | Min d20 to succeed | Success rate |
|---|---|---|---|---|---|
| Healthy ship, skilled captain (aff=50, int=1.0) | +5 | +5 | +10 | ≤0 (auto) | 100% |
| Healthy ship, unskilled captain (aff=20, int=1.0) | +2 | +5 | +7 | 1 | 100% |
| Damaged drive, skilled captain (aff=50, int=0.3) | +5 | +1 | +6 | 2 | 95% |
| Badly damaged (aff=20, int=0.1) | +2 | +0 | +2 | 6 | 75% |
| Nearly wrecked (aff=10, int=0.05) | +1 | +0 | +1 | 7 | 70% |
Design intent: a healthy ship with a competent captain should transit reliably. Engagement failure is a consequence of damage, not routine. Pushing a damaged ship is risky; losing drive integrity through wear or combat makes each subsequent transit less certain.
Failed engagement: The drive does not fire. Drive charge is preserved (the drive failed to activate, not expended and missed). A 15-second cooldown is imposed before the captain can attempt engagement again. The CommandResult includes the roll details so clients can display the failure reason.
Compromised drive: If driveIntegrity ≤ 0 (isCompromised() == true), the engagement automatically fails with no roll. The drive is non-functional until repaired.
When the arriveZone event fires at the end of transit, the server rolls for landing accuracy:
accuracyRoll = d20() + floor(shipAffinity / 10) + longDistancePenalty + lowChargePenalty
longDistancePenalty = (distance > 100) ? −2 : 0
lowChargePenalty = (chargeAtEngagement < 0.5) ? −2 : 0
Low-charge accuracy penalty: When the drive was engaged below 50% charge, accuracy also takes a −2 penalty. Emergency partial-charge jumps are less precise — the captain trades navigational accuracy for speed of escape. This penalty stacks with the long-distance penalty for a maximum of −4.
| Roll result | Outcome | Effect |
|---|---|---|
| ≥ 12 | Precise arrival | Vessel arrives at the intended destination zone. |
| 6 – 11 | Drift | Vessel arrives at the nearest zone to the intended destination (by coordinate distance, excluding origin zone). If the intended destination is the nearest zone, this counts as precise arrival. |
| 2 – 5 | Significant drift | Vessel arrives at a random zone (uniform selection, excluding origin zone). |
| Natural 1 (before modifiers) | Critical failure | Vessel arrives at a random zone (excluding origin) and takes 10% hull damage (hullIntegrity −= 0.10) and 20% drive integrity damage (driveIntegrity −= 0.20). The inertial dampener failed during transit; the captain was slammed around the lifepod. |
At baseline (shipAffinity 50, modifier +5, distance ≤ 100): rolls of 7+ give precise arrival (70%). At long range (distance > 100, modifier +3): needs 9+ for precise arrival (60%), drift on 3–8 (30%).
A natural 1 on the d20 is always a critical failure regardless of modifiers (5% base rate). This represents the fundamental unpredictability of the Artifact Drive — even the best captains cannot fully control it.
Drift resolution: The “nearest zone” for drift outcomes is computed by coordinate distance from the intended destination, not from the origin. This means a drift on a long haul might still land the captain fairly close to where they wanted to go. The “random zone” for significant drift and critical failure is a uniform random selection from all zones in the sector excluding the origin zone.
The Inertial Dampener is a field system that makes survival at Artifact Drive velocities and high-g combat maneuvering possible. Without it, the accelerations involved would be immediately fatal. Key properties:
evaTraining and dampener system health.ArtifactDriveSystem carries a driveIntegrity field [0–1] alongside its existing chargeRate and engagement state. Wear is deterministic and tick-driven; stochastic outcomes (engagement success/failure rolls) happen at command time in EngageDriveHandler and at arrival in the arriveZone event dispatcher.
Two sources of wear are applied each tick by SimulationController.updateVessel:
ArtifactDriveSystem.applyTick(dt) deducts wearRate × dt from driveIntegrity while the drive is engaged. Default wearRate is 0.001/s (1000 seconds of continuous engagement to fully degrade from 1.0 to 0.0).updateVessel reads the vessel’s InertialDampenerSystem.integrity and calls driveSystem.applyExternalWear(wearRate × (1.0 − dampenerIntegrity) × dt). This is linear: a fully intact dampener contributes zero cross-wear; a fully compromised dampener doubles the per-tick degradation rate.Because the drive now stays engaged for the full transit duration, self-wear is a meaningful mechanic. Representative wear per transit:
| Route | Transit Time | Self-Wear (0.001/s) | Transits to 50% integrity |
|---|---|---|---|
| earth → mars-transit | 10s | 0.010 (1.0%) | ~50 |
| earth → inner-belt | 35s | 0.035 (3.5%) | ~14 |
| earth → outer-belt | 64s | 0.064 (6.4%) | ~8 |
| inner-belt → outer-belt | 29s | 0.029 (2.9%) | ~17 |
A captain making long hauls will need to factor drive maintenance into their planning. Short hops are nearly free on drive integrity; a captain running the inner-belt/outer-belt cargo loop will need drive repair approximately every 6 round trips.
When driveIntegrity reaches 0, isCompromised() returns true. Engagement automatically fails with no roll.
Repair paths: Artifact station docking and crewdroid field repair using spareParts. Neither is implemented; driveIntegrity is in place to receive them.
ArtifactDriveSystem and InertialDampenerSystem are both implemented as ISystem subclasses in core.system. ResourceState carries a driveCharge field; charge accumulates each tick via ResourceRegistry.accumulateDriveCharge(), called in SimulationController.updateVessel after the resource drain step. The command kind for drive engagement is engageDrive, handled by EngageDriveHandler. The existing move-vessel path remains for conventional in-Zone repositioning and docking.
Vessel gains two transit fields: transitDestination (string, target zone ID; empty when not in transit) and transitArrivalTime (double, simulation time of arrival; 0 when not in transit). A helper isInTransit() returns transitDestination != "". These fields are included in Vessel.toJSON() and persisted in vessel documents.
ZoneCoords (in core.zone) gains a distanceTo(ZoneCoords other) method returning the Euclidean distance. Constants FUEL_COST_PER_UNIT (0.5) and TIME_PER_UNIT (0.35) are defined in core.controller or commands.engageDriveHandler.
The engageDrive event dispatch path in SimulationController is expanded: compute distance, roll engagement, check fuel, deduct fuel, enter transit, enqueue arriveZone event. A new event kind "arriveZone" is dispatched at the scheduled arrival time: roll accuracy, place vessel in determined zone, disengage drive.
Both driveIntegrity (on ArtifactDriveSystem) and integrity (on InertialDampenerSystem) are persisted to MongoDB via the SystemHealthRecord struct added to db.gameStateRepo. GameSnapshot carries a systemHealth array (one record per system-id); snapshot() and restore() in SimulationController read and write it. The systemHealth key is absent in snapshots written before this feature was added; GameSnapshot.fromJSON defaults to an empty array for backward compatibility. The in-memory reset path (captureSystemEvents) also captures and replays driveIntegrity via a "setDriveIntegrity" SystemControlEvent.
WeaponSystem is a concrete subclass of ISystem, living in core.system alongside EngineSystem, ShieldSystem, ArtifactDriveSystem, and InertialDampenerSystem. Like all systems it is owned by the SystemRegistry inside SimulationController and keyed by a string system ID. The ISystem contract (“what vessel do you belong to?”, “are you compromised?”) is the only interface the combat loop needs; the rest of the field set is WeaponSystem-specific and only touched by the combat subsystem and the two targeting commands.
Each singleship has exactly one weapon mount point. The Artifact issues every new singleship with a Pulse Lance — a directed-energy weapon integrated at fabrication. The Pulse Lance is the sole Artifact weapon template currently in use. The blueprint system supports multiple templates (random selection from the pool of Artifact blueprints weighted by quality tier), but only one template is registered at present; additional Artifact weapon designs are a future expansion hook.
Human-manufactured weapons are purchasable at market stations. Because each singleship has a single mount point, acquiring a new weapon requires selling or discarding the currently installed weapon and installing the replacement — a swap, not an addition. The transaction happens at a market station and is modeled as a future command kind (swapWeapon): validate that the vessel is docked at a station with a market, verify the captain has sufficient Credits, remove the old weapon instance, deduct Credits, and instantiate the new weapon from its blueprint.
A captain who swaps their Artifact-issued Pulse Lance for a human-manufactured weapon cannot recover it. The Artifact replaces the weapon only when it replaces the entire ship (respawn after destruction). This makes the swap decision strategically significant: human weapons offer variety and repairability at the cost of the Pulse Lance’s reliability and efficiency.
WeaponBlueprint is a read-only template struct stored in the weaponBlueprints MongoDB collection. The server loads all blueprints at startup into an in-memory BlueprintRegistry; the running simulation never writes back to that collection. A blueprint carries:
blueprintId, name, description, category ("artifact" | "human"), isArtifactWeapon.damageMin/Max, accuracyMin/Max, fireCycleMin/Max, powerDrawMin/Max. Final stats are rolled within these bounds at instantiation time.malfunctionChance, powerEfficiency (< 1.0 = less efficient), requiresSpareParts. All are zero / 1.0 / false for Artifact weapons.QualityTierEntry[] array, each with a tier name ("standard" | "superior" | "exceptional"), a statMultiplier applied to every rolled stat, and a weight for the random draw.Blueprints are managed by db.blueprintRepo.BlueprintRepository : IBlueprintStore (MongoDB backend) and core.blueprint.BlueprintRegistry (in-memory lookup). The repository uses the weaponBlueprints collection with upsert semantics; the registry provides get(id), byCategory(cat), and artifactBlueprintIds() helpers.
The factory function instantiateWeapon(blueprint, vesselId, dice) (in core.weapon) is called by buildScenario() when arming a vessel. It is the sole site where randomness is introduced into weapon state. The lifecycle of each field is:
| Field | Source | Persisted? | Notes |
|---|---|---|---|
blueprintId, isArtifactWeapon | Copied from blueprint | No (structural) | Part of the structural skeleton rebuilt by buildScenario() on every cold start. |
qualityTier | Weighted random draw from blueprint.qualityTiers | No (structural) | Determines the statMultiplier applied to all rolled stats. |
damage, accuracy, fireCycle, powerDraw | Random in [min, max] × tier multiplier | No (structural) | Rolled once at instantiation; fixed for the weapon’s lifetime. Re-rolled on cold start if the dice seed is not deterministic. |
malfunctionChance, powerEfficiency, requiresSpareParts | Copied from blueprint (penalty fields) | No (structural) | Constant for the weapon’s class; not modified by quality tier or wear. |
weaponIntegrity | 1.0 at instantiation | Yes — via SystemHealthRecord in GameSnapshot | Degrades each firing cycle. Persisted alongside drive and dampener integrity so crash recovery preserves weapon wear. |
targetId | "" (idle) at instantiation | No | Set/cleared by engageTarget / ceaseTarget commands. Reset to idle on any server restart — a restarting server never resumes mid-engagement. |
cycleTimer | 0.0 (ready) at instantiation | No | Countdown to next allowed shot. Lost on restart; weapon fires immediately on its first eligible tick after resumption. |
Known gap: The rolled combat stats (damage,accuracy,fireCycle,powerDraw,qualityTier) are part of the structural skeleton and are not in theGameSnapshot. If the server’sDiceinstance is not seeded deterministically, a cold start produces different rolled stats than the previous run. Adding a seeded or recorded roll to the snapshot is a deferred milestone.
Step 4.5 of SimulationController.updateVessel(vesselId, dt), inserted between the per-system applyTick pass and the final ResourceRegistry.setState call, runs the weapon fire cycle:
WeaponSystem on the vessel: skip if isCompromised or targetId is empty.cycleTimer by dt. Skip if still positive (weapon is still on cooldown).powerCost = powerDraw × powerEfficiency. Skip if the vessel’s current power is insufficient.powerCost from the local ResourceState being accumulated for this tick.roll = d20.roll() + captainTacticalSkill / 10 + (accuracy − 50) / 10. If no crewdroid is on duty at the bridge, compute roll = d20.roll() + (accuracy − 50) / 10 (no skill bonus).roll ≥ WEAPON_HIT_THRESHOLD (12), call applyWeaponDamage(targetId, weapon.damage).weaponIntegrity by HUMAN_WEAPON_WEAR_PER_SHOT (0.01) per firing cycle regardless of hit or miss. (Artifact weapon wear rates are differentiated via the blueprint’s field values; the 0.01 constant applies to human weapons. Artifact weapons have negligible wear by design.)cycleTimer = fireCycle.Because the tick fiber and HTTP handlers share one OS thread under Vibe.d’s cooperative scheduler, no mutex is needed. Resource changes within step 4.5 are written to the resources local copy and committed together with all other per-tick changes in the single setState call at step 5, making each tick’s resource accounting atomic.
applyWeaponDamage(targetVesselId, damage) is a private method on SimulationController. The damage value on WeaponSystem is in hull-fraction units [0, 1] (e.g. 0.08 means 8% of full integrity per hit). Damage is applied directly without division. Resolution proceeds in two phases:
ShieldSystem in ShieldState.raised and not compromised, shieldIntegrity −= damage, clamped to 0. No hull damage occurs. A shield at zero integrity on the next hit is compromised and passes all damage to the hull.vessel.hullIntegrity −= damage, clamped to 0. If the result is ≤ 0 and the vessel is not already in the destruction queue, its ID is appended to _destroyedThisTick.Vessel destruction cannot happen synchronously inside the foreach (vesselId, _; byVessel) iteration in tick() because removing a vessel from ResourceRegistry during iteration mutates the associative array being iterated. Instead, applyWeaponDamage pushes the ID into a private string[] _destroyedThisTick scratch buffer (with a canFind dedup guard to handle multiple weapons targeting the same vessel in the same tick). After the per-vessel loop completes, tick() iterates _destroyedThisTick and calls destroyVessel(id) for each entry.
destroyVessel(vesselId): creates a Wreck entity from the vessel’s zone, class, hull integrity, and current cargo; adds it to WreckRegistry; clears every other vessel’s WeaponSystem.targetId that references the destroyed vessel; removes the vessel from EntityManager and its resource state from ResourceRegistry.
Two command kinds manage WeaponSystem.targetId directly without enqueueing SimEvents — targeting is a pure system-state mutation with immediate effect, consistent with the architecture’s pattern of “direct mutation for system state, SimEvents for deferred domain changes”:
| Kind | Handler | Effect |
|---|---|---|
engageTarget |
EngageTargetHandler (commands/engageTargetHandler.d) |
Sets wpn.targetId = targetVesselId. Validates: weapon exists and belongs to the declaring vessel, weapon is not compromised, target vessel exists, no self-targeting. |
ceaseTarget |
CeaseTargetHandler (commands/ceaseTargetHandler.d) |
Clears wpn.targetId = "". Idempotent (already-idle weapon accepted). Reports previousTarget in the response details. |
The EngineSystem carries a maxPower field (default 1000 for the standard singleship) that acts as a consumption scalar. All engine-related fuel and power drain is multiplied by maxPower / 1000, allowing future ship classes with larger or smaller engines to consume proportionally more or fewer resources without changing the base rate constants.
fuelUse = (maxPower / 1000) × baseFuelRate × throttle × dt
powerUse = (maxPower / 1000) × basePowerRate × throttle × dt
baseFuelRate = 1.0 (units per second at full throttle, standard engine)
basePowerRate = 1.0 (units per second at full throttle, standard engine)
At maxPower = 1000 (standard singleship), the multiplier is 1.0 and behavior is identical to the hardcoded rates used prior to this change. A future heavy cruiser with maxPower = 2000 would consume fuel and power at double the rate; a scout with maxPower = 500 would consume at half the rate.
Implementation note: The existing Vessel.update() hardcodes rates of 1.0. The change is to read maxPower from the EngineSystem in the systems array and apply the multiplier. The ResourceRegistry.consumeForEngines() helper already accepts configurable rate parameters and can be used as an alternative call site.
Power is the critical operational bottleneck for a singleship in combat. It is consumed by engines, shields, shield regeneration, and weapon fire. The balance between starting power, shield maintenance cost, and combat drain rates determines how long a captain can sustain combat operations before being forced to disengage or face systems failure.
The standard singleship blueprint provides 350 starting power (increased from 200). This change reflects the design intent that a singleship should be able to sustain a meaningful combat engagement without power being exhausted before tactical decisions can play out.
Shield maintenance rate remains at 0.5 power/sec while raised. This is a significant ongoing cost that forces a real tradeoff between defensive posture and operational endurance. Shields are not free — running them continuously drains operational reserves.
| Scenario | Power drain/sec | Endurance (350 power) | Previous (200 power) |
|---|---|---|---|
| Full throttle + shields (typical combat) | 1.5 | 233s (3m 53s) | 133s (2m 13s) |
| Half throttle + shields (cruising) | 1.0 | 350s (5m 50s) | 200s (3m 20s) |
| Shields only, idle (defensive posture) | 0.5 | 700s (11m 40s) | 400s (6m 40s) |
| Full throttle, no shields (evasion) | 1.0 | 350s (5m 50s) | 200s (3m 20s) |
The 233-second combat window at full engagement is long enough for multi-round weapon exchanges and tactical maneuvering, while still creating pressure to manage power reserves. A captain who enters combat with depleted power from extended shield use or engine running is at a real disadvantage.
At Earth (cheapest station): full power refill = 350 × 12 CR/unit = 4,200 CR. At outer-belt (most expensive): 350 × 25 = 8,750 CR. The emergency stake (1,000 CR) buys approximately 83 power units at Earth prices — enough to restore meaningful operational capability but not a full tank.
ShipBlueprint.standardSingleship() in core/artifact.d is updated: resources.power changes from 200.0 to 350.0. The audit document’s refueling costs (Section 4.4) should be recalculated with the new value. Existing vessel documents in MongoDB will retain whatever power level they had at their last save; only newly fabricated ships receive the new default.
The simulation has a functional economy. Captains earn by extracting resources or hauling cargo; they spend on supplies, equipment, and crewdroids. The economic layer is intentionally lightweight — the point is to create meaningful tradeoffs and motivate activity, not to simulate a full market economy.
Credits are the interoperable currency of the solar system, not issued by any one faction. Credits live on the Character record, not on the ship — because a captain survives the loss of their ship (the Artifact replaces it), but their financial position carries forward. A captain who loses their ship is not broke; they are shipless.
Crewdroids can be dispatched on EVA to an asteroid, ice body, or other raw resource. The extraction command produces cargo at a dice-modified yield and quality. Higher engineeringSkill improves yield; the environment difficulty sets the base difficulty threshold.
Captains can accept cargo contracts from factions, stations, or other captains. Delivery is a standard command sequence: pick up, transit, dock, deliver. Payment is recorded in Credits against the captain’s Character.
The buySupplies command lets a captain spend Credits to purchase fuel, power, life support, or spare parts at a market station. The command payload specifies the vessel, station, resource type, and amount. The server validates that the vessel is in a zone with the specified market station, checks the captain’s CR balance against the total cost, deducts Credits, and adds the purchased resource amount to the vessel’s ResourceState.
Per-resource pricing is defined on Station with one price field per purchasable resource: fuelPricePerUnit, powerPricePerUnit, lifeSupportPricePerUnit, sparePartsPricePerUnit. Each has a sensible default so that stations without explicit pricing still function. The existing creditsPerCargoUnit on SellCargoHandler provides the model for this pattern.
Faction-based price variation and dynamic supply/demand pricing are noted as future enhancements. The initial implementation uses flat per-unit prices.
Character has a credits field persisted in MongoDB via CharacterRepository. ResourceState has a cargo field for generic extractable/tradeable material. Station entities (with hasMarket flag) exist in core.station and are managed by StationRegistry inside SimulationController. The implemented sell command is sellCargo, handled by SellCargoHandler: it validates zone co-location and enqueues a cargo-deduction SimEvent, while the server layer applies the Credits to the captain’s Character record after the command is accepted (Option C two-phase pattern). The buySupplies command follows the same two-phase CR-deduction pattern. Per-station pricing and extraction commands are future work.
Every ship, including the smallest singleship, is assigned at least two crewdroids. They are not Characters — they are simulation entities (Crewdroid extends Entity) managed by the EntityManager, with an energy model representing their charge level drawn from the ship’s power bus.
| Role | Primary activities |
|---|---|
Maintenance | Routine repair, diagnostic cycles, hull patching |
EVA | External hull work, resource extraction, wreck salvage, inter-ship transfers |
Security | Internal defense against boarding parties |
General | Unspecialized; capable of all roles at reduced effectiveness |
A droid’s role can be changed at runtime (setRole), subject to current energy and any active task.
All droid activity costs energy (performWork(energyCost)). A droid that lacks sufficient charge cannot perform work. Energy is replenished from the ship’s power bus (recharge(amount)), meaning crewdroid activity competes with engines, shields, life support, and drive charge for available power — a real resource tradeoff.
performWork() is the dice hook. Beyond the energy check, the success and quality of a work outcome is determined by a server-side roll modified by the droid’s current role, the captain’s relevant stat, and environmental difficulty.
Mob defense: When a boarding party enters the ship, Security and General droids form a defensive mob. The group roll is a single die modified by the number of combat-ready droids and their aggregate energy, tested against the boarding party’s attack difficulty. Outcomes are graduated: critical success, success, partial success, failure, critical failure.
EVA boarding support: EVA-capable droids can assist in or conduct boarding operations against another ship. The attack roll is modified by squad size, energy levels, and the captain’s evaTraining stat.
Crewdroids are registered in the EntityManager as typed entities, with a dedicated CrewdroidRegistry providing per-ship lookup and group queries (artifact-issued vs. recruited). The existing CrewRegistry and its Actor/CrewMember hierarchy are being converged into the crewdroid model: the activity-state FSM from CrewMember will be incorporated into the unified Crewdroid type so that all shipboard agents share a single entity class with energy, role, EVA capability, and activity tracking.
When a ship’s hull reaches zero, it becomes a Wreck entity in the simulation rather than being removed. The conquering captain’s crewdroids can then conduct salvage operations against it.
EVA droids dispatched to a wreck can extract cargo, supplies, and spare parts. The server tracks a true condition for every salvageable item, but the initial report to the salvaging captain is a dice-modified estimate. As droid examination time accumulates, the estimated condition converges toward the truth. A captain who rushes a salvage operation may load damaged goods without knowing it; one who takes time gets a more accurate picture — but time has costs of its own.
The conquered ship’s crewdroids, if intact, can be offered a choice. This is a dice roll modified by the recruiting captain’s commandSkill. Success adds those droids to the captain’s complement; failure leaves them inert or resistant. Recruited droids carry a small reliability penalty on critical tasks that diminishes with accumulated service time. They were made for someone else, and they seem to know it.
The intended long-term design allows crewdroids to extract discrete ship modules from a wreck — engine components, shield emitters, sensor arrays — and attach them to the salvaging ship. This requires a ShipModule type and ModuleRegistry not yet in the codebase. Cargo-only salvage is the first implementation; module extraction is explicitly deferred and documented here as the planned next layer.
A Wreck entity type is created when a Vessel’s hull reaches zero during combat resolution. New command kinds: scavengeWreck, recruitDroids. Crewdroid gains optional originalShipId and loyaltyLevel fields. The server tracks trueCondition and observedCondition per salvageable item; a WreckExaminationHandler improves the observed value toward truth with each dice-modified examination step.
The Artifact is a unique station that appeared in high Earth orbit without explanation. It is the physical anchor of the early game and the mechanical foundation of the spawn/respawn loop.
ShipBlueprint registry defines the ship classes it can produce. Initially only the singleship is registered; additional classes can be unlocked or discovered.Character.homeArtifactId (defaulting to "artifact-earth-orbit") records which Artifact a character is anchored to. When a ship is destroyed, the captain’s lifepod ejects and returns autonomously to the home Artifact for re-fabrication. Loss of a ship is a setback that costs time and possibly Credits, not existence.
The Artifact is an Entity in the simulation. Interactions (dock, request fabrication, request repair) are modeled as CommandRequests routed through the standard dispatcher with dedicated CommandHandler implementations.
The server runs an autonomous tick loop. Clients do not trigger simulation steps.
SimEvents whose scheduled time has arrived, updates resource consumption and system state for every active vessel, generates any universe-level random events, then produces outgoing snapshots for connected clients.desiredExecuteTime field on CommandRequest allows clients to schedule actions at a future simulation time, within reason.Current status: The tick loop is implemented as a server-driven Vibe.d background fiber (SectorServer.tickFiber()). The tick interval is configurable viatick_interval_msingalaxy.cfg. There is no client-driven tick endpoint.
Three GM-only HTTP endpoints let an operator control the running simulation without restarting the process. All three require an authenticated session with simRole == "gm"; sessions with role "pc" or "npc" receive 403 Forbidden.
| Endpoint | Effect |
|---|---|
POST /simulation/pause |
Sets SimulationController._paused = true. tick() becomes a no-op; wall-clock time advances but simulation time does not. HTTP handlers remain live throughout. |
POST /simulation/resume |
Clears the paused flag. Normal ticking resumes on the next fiber wake. |
POST /simulation/reset |
Calls SimulationController.reset(), which restores elapsed time (to 0), vessel resource levels, wreck loot state, and the event queue to the baseline captured at cold-start. Immediately flushes a MongoDB snapshot so the reset state survives a crash before the next periodic write. |
Each endpoint returns {"status": "paused"|"running"|"reset", "elapsedTime": <double>}.
SectorServer.checkGmAuth() is a private helper that first calls checkAuth() (returns 401 if no session) and then checks sess.get("simRole") == "gm" (returns 403 if not). Only users authenticated from a storyteller account of type "admin" receive simRole == "gm"; guest (pc) and user (npc) accounts are rejected.
reset() restores the mutable GameSnapshot layer. ISystem fields — engine throttle, shield state — are outside the snapshot contract and are therefore not restored. A future deep reset milestone will replay the initial system-control events from buildScenario() so that throttle and shield state are also consistent after a reset. See SESSION.md known issue 2 for design notes.
SectorServer calls SimulationController.captureInitialSnapshot(sectorId) once in its constructor, immediately after buildScenario() and before the MongoDB restore. This locks in the buildScenario initial values as the reset target. The baseline snapshot is stored in memory only and is never written to MongoDB.
The server maintains a periodic snapshot of all mutable simulation state so that it can survive cold starts, crashes, and planned restarts without losing more than a few seconds of progress.
The simulation uses a dual-path persistence model. Two complementary stores capture different aspects of the world:
elapsedTime), the pending SimEvent queue, per-vessel resource levels, wreck loot state, system health, and weapon instance data. This is the crash-recovery safety net: at most 5 seconds of progress can be lost.vessels MongoDB collection, one per vessel. Each document captures the full vessel definition: zone placement, resources, systems, crewdroid roster, and LocationGraph. Vessel documents are written at natural lifecycle boundaries: captain disconnect, graceful server shutdown, and onboarding. They are the canonical long-term store.Sector infrastructure — zones, stations, and wrecks — is persisted in three per-sector MongoDB collections (zones, stations, wrecks), each document carrying a sectorId field for multi-sector filtering. On startup, the server loads sector infrastructure from these collections via ISectorStore and passes the loaded data to buildScenarioFromData(), which constructs the SimulationController with zones registered by named string key via Galaxy.addZone(). If the collections are empty for the configured sector_id, the server auto-seeds the default Sol scenario (two zones, two stations, two wrecks) before proceeding. A GM endpoint (POST /sector/seed) can re-seed at any time.
Characters are persisted independently in the global characters collection (see Persistent Entities).
A GameSnapshot is written to MongoDB every 5 wall-clock seconds, checked at the end of each tick handler. A snapshot is also written immediately on graceful shutdown (SIGTERM or SIGINT). The snapshot for a sector is stored as a single document in the gameState collection, keyed by sector-id, and upserted on each write so there is at most one document per sector at all times.
Vessel documents are written to the vessels collection at lifecycle boundaries only (not on the 5-second tick). Each vessel document carries a lastSaved ISO-8601 timestamp.
The startup sequence reconstructs the world in layers:
sector_id via ISectorStore. If no data exists, auto-seed the default Sol scenario (two zones, two stations, two wrecks) via seedDefaultSector(). Pass the loaded data to buildScenarioFromData(), which constructs the SimulationController with zones registered by named string key. No vessels or characters are created. On a cold start with an empty MongoDB, the galaxy contains no ships.vessels collection. For each document, reconstruct the in-memory Vessel, its systems, crewdroids, and LocationGraph.characters collection. Bind each character to its assignedVesselId if present.GameSnapshot for the configured sector_id. If one exists and its capturedAt timestamp is newer than a vessel document’s lastSaved, overlay the snapshot’s resource/system/wreck state onto the in-memory objects (the snapshot wins for crash recovery). If no snapshot exists, proceed with the state loaded from vessel documents.SIGTERM and SIGINT are caught by an extern(C) nothrow @nogc signal handler that sets an atomic flag (g_shutdown). The background tick fiber polls this flag after every tick: if set, it writes a final snapshot, stops the HTTP listener, and exits the event loop. Because the fiber runs continuously, a SIGTERM is handled promptly regardless of HTTP activity.
Each server instance is assigned a sector_id in its configuration file (e.g. sector_id=sol). The gameState collection is filtered by sector-id, so multiple server instances sharing a MongoDB cluster do not interfere with each other’s simulation state. The characters collection is intentionally global and unscoped — a captain’s identity and financial position belong to the simulation at large, not to any particular sector server.
db.gameStateRepo defines GameSnapshot and its component record types (ResourceStateRecord, WreckStateRecord, SimEventRecord), the IGameStateStore interface, and MongoGameStateStore. db.sectorRepo defines ISectorStore and MongoSectorStore for sector-scoped CRUD on the zones, stations, and wrecks collections. SimulationController has snapshot(sectorId) and restore(snap) methods. SectorServer wires snapshot writes into the constructor (restore on startup) and tick handler (periodic + shutdown). app.d registers the signal handlers and passes the shutdown flag pointer to the server.
Characters and vessels are MongoDB-first entities. They exist as durable documents in their own collections, independent of buildScenario(), and survive server restarts without any snapshot overlay. This is the foundation of the persistent world model: a fresh MongoDB means an empty galaxy with no ships; the first captain to connect gets a ship manufactured by the Artifact.
buildScenario() is world-only. It creates zones, planets, stations, wrecks, and celestial bodies — the structural geography that exists regardless of whether anyone is flying in it. It does not create vessels, crewdroids, or characters. Those are player/NPC-owned data loaded from MongoDB or created through the onboarding flow (see Captain Lifecycle).
The persistent world uses four MongoDB collections, three of which are shared across all galaxy server instances:
| Collection | Scope | Keyed by | Purpose |
|---|---|---|---|
characters |
Global (cross-server) | character-id |
Captain identity, skills, Credits (CR), assignedVesselId, homeArtifactId, controlMode. Enhanced from the existing collection. |
vessels |
Global (cross-server) | vessel-id |
Full vessel state: zone, ship class, hull integrity, resources, system definitions and health, weapon instance data, crewdroid roster, LocationGraph edges. One document per vessel. |
activeSessions |
Global (cross-server) | _id = username |
Currently logged-in accounts. Fields: serverId, loginTime, lastHeartbeat, vesselId. Used for single-logon enforcement and disconnect detection. |
gameState |
Per-sector | sector-id |
Monolithic snapshot: simulation clock, pending SimEvent queue, per-vessel resource/system overlays. Crash-recovery safety net (see Persistence and Recovery). |
zones |
Per-sector | zoneId + sectorId |
Zone definitions: coordinates, planet list, display name. Loaded at startup; registered by named string key via Galaxy.addZone(). |
stations |
Per-sector | stationId + sectorId |
Station definitions: zone placement, market flag, Artifact affiliation, per-resource pricing. |
wrecks |
Per-sector | wreckId + sectorId |
Wreck structural definitions: zone placement, ship class, true cargo units. Dynamic state (loot flags) is in the gameState snapshot. |
Each vessel document captures everything needed to reconstruct a fully functional vessel in memory without buildScenario():
vesselId, shipClass (e.g. "singleship"), captainId (back-reference to owning character).zoneId, sectorId.hullIntegrity [0–1], maxHull, armorRating.fuel, power, lifeSupport, spareParts, driveCharge, cargo.WeaponInstanceRecord (blueprint provenance, rolled stats, integrity).inStasis (boolean), stasisSince (ISO-8601 timestamp, null if not in stasis).createdAt, lastSaved (ISO-8601 timestamps), blueprintId (which ShipBlueprint produced this vessel).Because the GameSnapshot is written every 5 seconds and vessel documents are written only at lifecycle boundaries, a crash may leave the snapshot more recent than the vessel documents. On startup, the server applies a simple conflict rule: load vessel documents first (they are the canonical store), then overlay the GameSnapshot if its capturedAt timestamp is newer than a vessel document’s lastSaved. This ensures that crash recovery never loses more than 5 seconds of progress while keeping vessel documents as the long-term source of truth.
The Artifact station fabricates new singleships from a ShipBlueprint (already stubbed in core/artifact.d). The standard singleship blueprint defines:
The blueprint is used by the onboarding endpoint to fabricate a new vessel when a captain has no assigned ship. Additional blueprints (larger ships, specialised variants) can be registered in the Artifact’s BlueprintRegistry as a future expansion.
core/composition.d: buildScenario() is refactored to world-only. Vessel, crewdroid, and system creation code moves to a new fabricateVessel(blueprint, captainId, zoneId) function used by the onboarding endpoint. db/gameStateRepo.d: gains VesselDocument struct with toJSON()/fromJSON(), IVesselStore interface, and MongoVesselStore implementation. characters/characterLoad.d: the existing ICharacterStore is enhanced with lookup-by-username and vessel-binding fields.
A captain’s relationship with the galaxy server follows a defined lifecycle: authenticate, onboard (create or reconnect), operate, and disconnect (graceful or detected). This section specifies each phase and the mechanisms that enforce session integrity across the fleet.
Only one active session per username is permitted at a time, across all galaxy server instances. This prevents a captain from being “in two places at once” and avoids the race conditions that would arise from duplicate command streams for the same vessel.
activeSessions MongoDB collection stores one document per logged-in username: { "_id": "<username>", "serverId": "<sector_id>", "loginTime": <ISO-8601>, "lastHeartbeat": <ISO-8601>, "vesselId": "<vesselId>" }.POST /login: After credential validation succeeds, the server checks activeSessions for the username. If a document exists with a lastHeartbeat within 30 seconds, the login is rejected with 409 Conflict (“Account already logged in”). If the heartbeat is stale (older than 30 seconds), the document is treated as abandoned and overwritten.simRole == "gm") always succeeds. If a session exists for the same username, it is removed (the previous session becomes invalid). This allows administrators to recover from stuck sessions.lastHeartbeat on the caller’s activeSessions document. Under future WebSocket transport, the connection itself provides liveness signalling.POST /onboardThe onboarding endpoint is the single entry point for a captain joining the simulation. It handles both first-time captains and returning captains.
| Scenario | Server action |
|---|---|
No Character document exists for this user |
Create a new Character with default stats, starting Credits (CR), homeArtifactId = "artifact-earth-orbit", and controlMode derived from session role. Fabricate a new vessel from the Artifact’s standard singleship ShipBlueprint, placed in the zone of the captain’s home Artifact station (Earth Orbit). Persist both documents. Return isNewCaptain: true. |
Character exists but has no assignedVesselId (post-destruction, awaiting respawn) |
Fabricate a new vessel from the blueprint. Update the character’s assignedVesselId. Persist. Return isNewCaptain: false. |
Character exists with a valid assignedVesselId |
Load the existing vessel. If it was in stasis, deactivate the stasis shield and apply the reconnect recovery bonus. Update controlMode. Return isNewCaptain: false. |
Response payload: { "characterId": "...", "vesselId": "...", "zoneId": "...", "isNewCaptain": true|false }.
All clients (PC and NPC) call POST /onboard after authentication. The endpoint is idempotent for returning captains.
When a captain disconnects — whether by closing the client, losing network, or crashing — the server activates a stasis shield around their vessel. This is an Artifact-origin protective mechanism: the lifepod detects that the captain has gone dormant and the ship’s Artifact systems autonomously project a defensive field that suspends all operations.
The server tracks lastActivity per authenticated session. Every API call refreshes this timestamp. A background check, piggybacked on the tick loop (every ~5 seconds), scans for sessions whose lastActivity exceeds the configurable disconnect_timeout_s (default: 60 seconds). Under future WebSocket transport, a closed connection triggers immediate disconnect handling.
"stasis": true on the vessel object.Character.controlMode transitions to "idle".activeSessions document is removed.The stasis shield offers complete protection but no tactical awareness. A captain who disconnects in the open — in a contested zone, near a station under dispute, or in proximity to hostile vessels — will return to find the universe moved on around them. Enemies can position themselves around a stasis-shielded vessel, waiting for the captain to reconnect and the shield to drop. Wise captains who anticipate going dormant will first move to a safe location: deep in friendly territory, docked at a station, or in an uncontested corner of an empty zone. Going into stasis without taking precautions is a calculated risk. The Artifact protects the ship; it does not protect the captain’s position.
There is no time limit on stasis. A vessel can remain in stasis indefinitely until its captain reconnects. The persistent world holds a ship’s place regardless of how long its captain is away.
When a captain reconnects (authenticates and calls POST /onboard), the stasis shield drops and normal operations resume. The Artifact’s restorative properties provide a reconnect recovery bonus: a modest boost to hull integrity and resource levels (configurable; approximately +5% hull, +10 fuel, +10 power, +10 life support), representing the beneficial effects of the stasis field’s dormancy period. controlMode is restored to the appropriate active mode ("humanGui" or "aiAgent").
Every HTTP route has an explicit access level. Routes not listed as public require an authenticated session cookie; routes marked GM-only additionally require simRole == "gm".
| Method | Path | Access | Description |
|---|---|---|---|
GET | * | Public | Serve static files from public/ with CORS headers. |
POST | /login | Public | Authenticate; establishes server-side session cookie. Enforces single active logon. |
POST | /onboard | Authenticated | Captain onboarding: create or reconnect character + vessel. |
GET | /characters/me | Authenticated | Load the authenticated captain’s own Character document. |
GET | /characters | GM only | Load all characters from MongoDB. |
POST | /characters | GM only | Upsert a character to MongoDB (full admin edit). |
GET | /game/state | Authenticated | Full galaxy + vessel snapshot as JSON. Filtered-by-vessel endpoint is a Chunk 15+ concern. |
GET | /game/state/size | Authenticated | Character count of the JSON state (diagnostic). |
POST | /commands/one | Authenticated | Submit a single CommandRequest for dispatch. |
POST | /crew/spawn | GM only | Spawn a new crewdroid onto a vessel (admin override). |
POST | /simulation/pause | GM only | Pause the simulation tick loop. |
POST | /simulation/resume | GM only | Resume the simulation tick loop. |
POST | /simulation/reset | GM only | Reset simulation to baseline snapshot. |
POST | /sector/seed | GM only | Re-seed sector infrastructure (zones, stations, wrecks) from default data. Overwrites existing sector collections for the configured sector_id. |
Implementation: SectorServer.checkGmAuth() (existing) handles the GM check. A new checkAuth() wrapper is applied to all authenticated routes. The activeSessions heartbeat update is applied as middleware on every authenticated API call.
See also: Transport and Server Communication in the Client Architecture document for the client-side transport abstraction and server API evolution plan.
Used for low-frequency, request/response interactions: authentication, character management, high-level one-off orders, on-demand state snapshots, and admin/GM operations including simulation lifecycle management (POST /simulation/pause, /simulation/resume, /simulation/reset).
Used for the high-frequency real-time channels:
Current status: Only the REST channel exists. WebSocket support is a future milestone; Vibe.d supports it natively.
Each command is a CommandRequest with: commandId, originType ("pc", "npc", "gm", "system"), originId, kind, payload, requestTime / desiredExecuteTime, and schemaVersion. No packet directly sets position, resource values, enemy state, or dice results.
Implemented command kinds: moveCrewdroid (captain directive to reposition a crewdroid within the vessel — crewdroids are autonomous enough to position themselves for routine tasks, but the captain can explicitly order a droid to a specific location for tactical reasons such as damage control, EVA staging, or weapons station manning), engageDrive (Artifact Drive engagement), salvageCargo (EVA cargo extraction from a wreck), sellCargo (sell all held cargo at a market station), engageTarget (arm a weapon system against a target vessel), ceaseTarget (disarm a weapon system). Planned future kinds include: setThrottle, dockAtArtifact, extractResource, buySupplies, swapWeapon, buyCrewdroid, scavengeWreck, recruitDroids.
Each command produces a CommandResult with a status ("accepted", "rejected", "partial", "error"), a details payload, and where applicable the dice roll record from resolution.
CommandRequest → CommandApi → CommandDispatcher → CommandHandler (one per kind) → CommandResult. Adding a new command type means implementing a CommandHandler subclass and registering it; the dispatcher and API are unchanged.
NPCs are headless clients running the same control protocol as PC clients. Scaling NPC population means adding more AI worker processes, not scaling the server core. Character.isHeadlessOnly marks NPCs that should never receive a UI snapshot. The NPC and PC client architectures are detailed in Client Architecture.
With distance-based transit, NPC pickDestination() must incorporate transit cost into destination selection. The previous approach — selecting destinations based solely on expected profit and safety — no longer works because a high-value wreck in the outer belt is not worth pursuing if the captain cannot afford the fuel or the transit time makes the effective profit rate low.
Each candidate destination is scored by expected profit per unit time:
score = expectedProfit / (chargeTime + transitTime + operationTime)
chargeTime = 33 (flat, seconds at 0.03/s)
transitTime = distance × TIME_PER_UNIT (0.35 s per unit)
operationTime = 30 (estimated salvage/sell cycle, seconds)
expectedProfit = estimated cargo value at destination (from zone summaries)
The NPC selects the destination with the highest score, subject to fuel feasibility.
Before considering a destination, the NPC checks:
fuelCost = FUEL_COST_PER_UNIT × distance
available = currentFuel − FUEL_LOW_THRESHOLD (20.0)
feasible = (fuelCost ≤ available)
Destinations that cannot be reached with available fuel are excluded entirely. This prevents NPCs from stranding themselves in remote zones.
The XO safety layer (clientLib/xo/safety.d) provides a distance-aware fuel advisory. Instead of the simple FUEL_LOW_THRESHOLD check, the XO computes the fuel cost to return to the nearest Artifact-affiliated station and warns or rejects navigation goals when remaining fuel would be insufficient for the return trip. This is a soft advisory (the captain can override it), distinct from the server-side hard fuel check that prevents engagement when fuel is strictly insufficient.
The distance-based system significantly changes NPC economic loop timing. Representative full trade cycles (salvage at wreck zone, sell at market, return to refuel):
| Loop | Old timing | New timing | Fuel used |
|---|---|---|---|
| earth → outer-belt → inner-belt → earth | ~330s | ~696s | 183 |
| earth → mars-transit → earth | ~230s | ~288s | 29 |
| inner-belt → outer-belt → inner-belt | ~230s | ~394s | 82 |
Short loops near Earth remain quick and cheap. Long-haul outer-belt runs take roughly double the time and consume 36% of starting fuel per cycle. The scoring formula naturally steers NPCs toward nearby profitable destinations when fuel is limited, and toward high-value distant runs when they have a full tank.
Status: deferred. The initial NPC client will operate on game state alone, without communication capabilities.
When implemented, ship-to-ship communication will use a controlled vocabulary / signal system rather than free-form text. The vocabulary includes structured signal types: hail, threat, trade request, distress, surrender, and similar predefined intents. This approach keeps the protocol machine-readable and ensures NPC captains can participate in communication on equal footing with PC captains.
The final NPC client should have full comms capabilities so that it behaves identically to a PC client from the server’s perspective. The communication system will be modeled as a command kind (e.g. sendSignal) with a payload specifying the signal type, target vessel, and any structured parameters. Received signals will appear in the game state snapshot for the recipient vessel.
Status: implemented (zone-scoped, Phase 1). The filtered state endpoint
GET /game/state?vessel={vesselId} returns zone-scoped data.
The unfiltered endpoint (no query parameter) remains available for GM tools.
A captain sees all entities in their current zone at full accuracy: vessels, stations, wrecks, and planets. Nothing outside the current zone is included in the detailed data. This is the fundamental fog-of-war boundary.
For all zones other than the captain’s current zone, the server includes
a lightweight zoneSummaries array. Each summary contains:
Zone metadata (ID, coordinates, planets) is always included in full for all zones, so the captain always knows where they can navigate.
Zone summary data is subject to sensor noise, giving
Vessel.tacticalSkill its first gameplay purpose. The noise formula:
noiseFactor = baseFuzz * (1.0 - tacticalSkill / 100.0 * 0.7)
baseFuzz = 0.15 (±15%)
tacticalSkill 0 → ±15.0% noise
tacticalSkill 50 → ±9.75% noise
tacticalSkill 100 → ±4.5% noise
Station prices are multiplied by 1.0 + uniform(-noiseFactor, +noiseFactor).
Wreck counts are offset by round(uniform(-1, +1) * noiseFactor / baseFuzz),
clamped to ≥ 0. Noise is re-rolled on every state request — the captain receives
a fresh “sensor reading” each poll.
Uses the existing Dice module for server-authoritative randomness.
A future phase will add noise to observed data for other vessels within the
captain’s zone — resource levels reported with ±10–20% error, hull
integrity slightly off, possible missed detections for damaged sensor systems. This will
further differentiate fully-operational ships from damaged ones and give
tacticalSkill additional tactical depth.
When the ?vessel= query parameter is absent, GET /game/state
returns the full unfiltered snapshot with no zone summaries. This preserves backward
compatibility for galaxyManager and any future GM dashboards.
{
"galaxy": { "zones": [...] }, // all zones (id, coords, planets)
"vessels": [...], // only vessels in captain's zone
"stations": [...], // only stations in captain's zone
"wrecks": [...], // only wrecks in captain's zone
"zoneSummaries": [ // all OTHER zones
{
"zoneId": "mars-orbit",
"stations": [ { "id", "displayName", "hasMarket",
"creditsPerCargoUnit", ... } ],
"unlootedWreckCount": 2
}
],
"time": 123.4,
"paused": false
}
For a more detailed breakdown across the three client layers (Decision, AI/XO, Operations), see Division of Work Revisited in the Client Architecture document.
| Layer | Responsibilities | Approx. CPU share |
|---|---|---|
| Client (PC or NPC) | UI rendering, AI decision-making, XO planning, local prediction, control frame generation, economy decision-making | 70–80% |
| Server | Physics integration, command validation, dice resolution, resource update, event dispatch, crewdroid work resolution, salvage examination, economy transaction validation, snapshot generation, sensor filtering | 20–30% |
| Concept | Module(s) | Status |
|---|---|---|
| Authoritative simulation core | core.controller.SimulationController | Exists |
| Galaxy (Zone manager for one Sector) | core.galaxy | Exists |
| Zones (orbital regions, formerly Sectors) | core.zone | Exists |
| Entities and registry | core.entity.Entity, core.entity.EntityManager | Exists |
| Vessels | core.vessel.Vessel | Exists |
| Ship systems (engines, shields) | core.system.ISystem, EngineSystem, ShieldSystem | Exists |
| Weapon system | core.system.WeaponSystem | Exists (combat stats, integrity, targetId, cycleTimer) |
| Weapon blueprints (reference data) | core.blueprint.WeaponBlueprint, BlueprintRegistry | Exists (loaded from MongoDB at startup; read-only during simulation) |
| Weapon blueprint persistence | db.blueprintRepo.BlueprintRepository, IBlueprintStore | Exists (weaponBlueprints MongoDB collection; upsert semantics) |
| Weapon instantiation factory | core.weapon.instantiateWeapon | Exists (rolls stats from blueprint ranges and quality tier) |
| Combat loop (per-vessel, per-tick) | SimulationController.updateVessel step 4.5; applyWeaponDamage; destroyVessel | Exists (d20 to-hit, shield absorption, hull damage, deferred destroy) |
| Targeting commands | commands.engageTargetHandler.EngageTargetHandler, commands.ceaseTargetHandler.CeaseTargetHandler | Exists (arm / disarm a weapon; no SimEvent queue) |
| Artifact Drive system | core.system.ArtifactDriveSystem | Exists (includes driveIntegrity, wearRate, applyTick self-wear, applyExternalWear) |
| Inertial Dampener system | core.system.InertialDampenerSystem | Exists |
| System health persistence | db.gameStateRepo.SystemHealthRecord, GameSnapshot.systemHealth | Exists (drive integrity + dampener integrity persisted to MongoDB per-sector snapshot) |
| Locations and movement graph | core.location.LocationGraph | Exists |
| Crewdroid activity FSM | core.crew.ActorActivity, core.crew.nextActivity | Exists (to be merged into unified crewdroid type) |
| Crewdroids and registry | characters.crewdroid.Crewdroid, CrewdroidRegistry | Exists (convergence with CrewMember FSM planned) |
| Resources and consumption | core.resource.ResourceRegistry, ResourceState | Exists (including driveCharge and cargo fields) |
| The Artifact (ship fabrication/repair) | core.artifact.Artifact, ShipBlueprint | Exists |
| Wreck entities | core.wreck.Wreck, core.wreck.WreckRegistry | Exists |
| Ship modules (future) | core.module.ShipModule, ModuleRegistry | Deferred milestone |
| Station / Market entities | core.station.Station, core.station.StationRegistry | Exists |
| Credits (on Character) | characters.characterTypes.Character.credits | Exists |
| Typed ID safety | core.ids.TypedId | Exists |
| Dice rolls and randomness | utilities.dice.Dice | Exists |
| Command protocol | commands.request.CommandRequest, commands.model.Command/CommandResult | Exists |
| Command routing | commands.dispatcher.CommandDispatcher, commands.handler.CommandHandler | Exists |
| HTTP entry point | commands.api.CommandApi | Exists |
| Scenario wiring | core.composition.buildScenario | Exists |
| Character types, stats, serialization | characters.characterTypes.Character | Exists |
| Character persistence contract | core.db.ICharacterStore | Exists |
| Game state snapshot and recovery | db.gameStateRepo.GameSnapshot, IGameStateStore, MongoGameStateStore | Exists |
| Sector infrastructure persistence | db.sectorRepo.ISectorStore, MongoSectorStore; core.composition.seedDefaultSector, buildScenarioFromData | Exists (zones, stations, wrecks collections; auto-seed on empty; POST /sector/seed) |
| HTTP server and route handlers | server.SectorServer | Exists |
| Simulation lifecycle (pause / resume / reset) | SimulationController.pause(), resume(), reset(), captureInitialSnapshot() | Exists |
| GM-only route enforcement | SectorServer.checkGmAuth() | Exists |
| Galaxy Router / Federation layer | TBD | Future milestone |
| Configuration (server) | utilities.config.Config | Exists |
| Configuration (manager client) | config.managerConfig.ManagerConfig in galaxyManager/ | Exists |
| String utilities | utilities.stringutils | Exists |