Skip to content

Fleet Movement Simulation Model

How the game advances fleet position over time and makes that position visible to other players, without simulating every tick of game time.

It does not describe final game behavior. It captures the current direction and unresolved design choices.


Goal

Define a simulation model for fleet movement that is accurate, performant, and correct under cross-player observation — without a global tick loop that advances every fleet every second.

The model must answer two different questions:

  • For the fleet's owner: where is my fleet, and when does it arrive?
  • For everyone else: which fleets are in this system right now?

These are not the same question, and they do not have the same solution.


Core Problem

A fleet's journey is driven by elapsed time, not by player actions. A fleet hops from system to system on a timer — for example, one hop every few minutes — and the game must reflect each hop even when nobody is actively looking at that fleet.

Three forces make a naive approach fail:

  1. No ticking. Simulating every fleet every second across the whole universe is computationally wasteful and does not scale.
  2. Cross-player visibility. Another player asks "who is in system X right now?" — an inverted, by-system query. This cannot be answered by lazily projecting one fleet's path on demand. System occupancy must be real state, materialized at the moment a fleet enters or leaves.
  3. Mid-flight interaction. A player can cancel a journey, a fleet can be seen entering or leaving, and random events can interrupt a fleet and demand a response. Each of these is a discrete moment that must happen at a real time.

The resource model (see Resource Calculation) solves a related problem with pure lazy projection. Movement cannot reuse that solution wholesale, because resource state is only ever read by its owner, while fleet position is observed by others and triggers side effects.


Working Directions

Direction 1 — Pure lazy projection (rejected as the whole answer)

Store departure time, route, and per-hop duration. Compute current position on read: hop = floor(elapsed ÷ hop_duration). No state changes between reads.

This is elegant and free — and it is correct for the owner's own view of their own fleet. It fails as a complete model because it cannot answer the by-system query ("who is here now?") without scanning every fleet in the universe, and because a hop that nobody reads still must produce observable effects (entry, exit, detection).

Lazy projection survives in this model, but only for the owner-facing ETA view.

Direction 2 — Continuous ticking (rejected)

A global loop advances every fleet on a fixed interval. Simple to reason about, but does not scale and wastes work advancing fleets that nothing is observing. Listed only to be explicitly ruled out.

Direction 3 — Discrete-event scheduling (leading direction)

The game does not tick. Instead, at the moment a journey starts, the next meaningful moment is computed and a single durable timer is scheduled for exactly that time. When the timer fires, the game resolves that transition, then schedules the next one.

For a fleet, each hop is a scheduled transition. When the hop timer fires:

resolve hop:
  fleet leaves current system
  fleet enters next system
  if at destination or journey cancelled → stop
  else → schedule next hop

This is discrete-event simulation: work happens only at the exact moments something changes, and the database carries the schedule so it survives server restarts. The same primitive can drive other timed game state (resource snapshots, build completion), not just movement.

Direction 4 — Atomic-hop occupancy ("a fleet is always in exactly one system")

Model a hop as atomic. The fleet dwells in system N for the hop duration, then jumps to system N+1. It is never "halfway between" systems — at any instant it is in one named system.

This single modelling choice resolves several otherwise awkward cases:

  • Cancel mid-flight becomes unambiguous: the fleet is already in a concrete system, so "stop in the current system" just means "do not schedule the next hop."
  • Occupancy is always well-defined: every fleet has exactly one current system.
  • Entry / exit events are clean discrete facts at hop boundaries.

The alternative — modelling the fleet as in-transit "on an edge" between systems — makes cancellation and occupancy ambiguous (does it snap back? finish the hop?). The atomic model is the simpler foundation.

Direction 5 — Materialized occupancy projection (cross-player visibility)

Maintain a per-system read model — system → fleets currently present — updated by the leave/enter events that the scheduled transitions emit. This is what other players query. It is materialized real state, not a lazy derivation, so a fleet appears in a system the instant the hop event fires and disappears the instant it leaves.

This projection is the direct answer to "who is in system X right now?" and is the reason movement cannot be purely lazy.

Direction 6 — Hybrid read model

Combine the two: lazy projection for the owner's private view (route progress, ETA, no writes), and the materialized occupancy projection for everyone else's view. The authoritative position lives in the scheduled transitions; the owner's progress bar is just a free interpolation between the last and next transition.

Direction 7 — Invalidation by version token

A journey can be cancelled, rerouted, or disrupted while a hop timer is already in flight. Rather than racing to delete the scheduled timer, stamp each journey with a movement version. The scheduled transition carries the version it was created under; when it fires, it checks the fleet's current version and harmlessly no-ops if it is stale. Cancellation and rerouting then simply bump the version and schedule fresh timers.

This keeps cancellation correct even when a command and a firing timer collide.

Direction 8 — Interaction halts

A random event or a route disruption (see Transit Events and Route Disruption) is resolved at hop boundaries. When one fires, the transition does not schedule the next hop. Instead it records a pending interaction and leaves the fleet stationary in its current system. Movement resumes — the next hop is scheduled — only when the player resolves the interaction (or a timed default applies).

Direction 9 — Fast-forward replay on missed time

A server can be down for minutes during a journey. The durable schedule re-delivers the pending timer after restart, but it only fires the next one, late — not the several hops that should have passed meanwhile. The model must heal that gap from a single late wake.

The key is to decouple two things: the scheduled timer is only a wake nudge, and the time elapsed is the source of truth. But unlike a pure projection, the landing system cannot be computed by jumping straight to f(now), because every hop has its own side effects — a gate toll to pay, a detection to record, a transit event to roll. Skipping intermediate hops would skip every toll and event between.

So a wake performs fast-forward replay: it loops through each hop that is now due, resolving each one fully and in order, with no wall-clock wait between them.

on wake:
  while next_hop_due <= now:
    resolve one hop:
      pay toll for the gate          // may fail
      emit left(N) / entered(N+1)
      roll transit event             // may halt
    if toll unpaid OR event halt OR blockade → stop, fleet stays at this gate
    else advance next_hop_due by one hop duration
  if not halted and hops remain → schedule the next wake at next_hop_due

A ten-minute outage with a five-minute hop replays both missed hops in order — paying each toll, firing each event — then schedules the next. A journey that finished entirely during downtime replays every hop through to arrival.

This is neither ticking (no real-time wait per hop) nor skip-to-final (no skipped side effects). It is bounded by route length, not by how long the server was down.

Because each hop can fail or halt, the landing system is not precomputable — it emerges from replaying each hop's outcome. A toll that cannot be paid strands the fleet at that gate mid-replay, exactly as it would have in real time.

Defense in depth — reconciliation sweep. Durable timers should survive restart, but the model should not depend on it. A periodic and on-startup sweep finds fleets whose next hop is overdue with no pending wake and resolves them through the same replay path. This catches lost, orphaned, or never-scheduled timers.

Idempotency. A crash after emitting a hop's events but before scheduling the next wake leaves the fleet re-resolvable by the sweep. Combined with the movement version token (Direction 7), a duplicate or stale wake harmlessly no-ops, so replaying a hop that already happened does not double-charge a toll or double-emit its events.


Open Questions

  • What is the granularity of a scheduled transition — one per hop, or one per journey leg with intermediate hops interpolated lazily? Per-hop is simplest for occupancy but creates more scheduled timers.
  • Should occupancy be visible to every player, or only to players with their own presence/sensors in that system? If visibility is gated, the occupancy projection needs a per-observer filter rather than a single global list.
  • Does entering a system emit one event, or separate "left N" and "entered N+1" events? Two events make the occupancy projection trivial but double the event count.
  • A fleet that sat through a long outage replays many hops in one wake. Is there a ceiling on how many hops a single replay resolves before yielding, to avoid one fleet monopolising a resolution pass?
  • During replay, each hop's toll credits another player and each entry/exit touches shared occupancy. When many fleets replay at once after an outage, in what order are these cross-fleet effects applied, and is eventual consistency across them acceptable?
  • How are large numbers of simultaneous transitions handled — can many fleets' hops that fall in the same instant be batched into one resolution pass?
  • Is fleet position derived purely from scheduled transitions, or is there also a persisted "current system" field on the fleet that the transitions update? The second is faster to read but must be kept consistent with the schedule.
  • When a journey is cancelled mid-hop, does the fleet stop in the system it is currently dwelling in, or does it complete the in-progress hop first? The atomic model allows either, but the choice affects how cancellation feels.
  • Should the movement scheduler and the resource snapshot trigger share one timed- event mechanism, or remain separate systems that happen to use the same primitive?
  • Is there a maximum look-ahead for scheduling (only ever schedule the next hop), or can a full journey schedule all its hops up front (relying on version tokens to invalidate stale ones)?