23 KiB
Architecture
This document captures the architectural decisions for the project. It is a complement to requirements.md; it explains how the game is built, not what it does.
Goals
- Keep the simulation testable with Catch2 in a headless environment.
- Keep game speed (0×–4×) and pause trivial to implement (REQ-UI-SPEED).
- Keep config-driven balancing (formulas, recipes, station and ship stats) fast to iterate on.
- Make the belt subsystem replaceable without touching the rest of the game.
- Let ship behavior grow (hybrid ships, new capabilities) without rewriting existing code.
Simulation / Presentation Split
A strict separation between the game simulation and the Qt Widgets UI.
- The simulation is a pure C++ library that depends only on Qt Core (QPoint, QVector2D, QRect, etc., as required by the coding guidelines), toml++, and tinyexpr. It contains no QtWidgets, no painting, and no QApplication.
- The UI reads simulation state and renders it. It owns all widgets, painting, and input handling, and drives the simulation via a small command interface (place building, demolish, clear belt tiles, change recipe, set game speed, etc.).
This split is enforced at the CMake target level (see below). Tests link only against the simulation library and run without a display server.
Fixed-Timestep Tick-Based Simulation
The simulation advances in discrete ticks. All game quantities — production timers, belt item progress, threat accumulation, wave timers, ship cooldowns — are measured in ticks, not wall-clock seconds.
- Tick rate: fixed at 30 Hz;
tickDurationMs = 1000 / 30 ≈ 33.33. - Ticks are driven by an accumulator that is independent of the render rate. Each render frame, the driver adds
elapsedWallMs × gameSpeedMultiplierto an accumulator and flushes onetick()pertickDurationMsof accumulated time (so multiple sim ticks may run between frames at high speeds, or a frame may run no ticks at low speeds).gameSpeedMultiplier∈ {0, 0.5, 1, 2, 4} per REQ-UI-SPEED; 0× freezes the accumulator (pause). The concrete driver lives in the Rendering section. - Config-level durations given in seconds (recipe durations, wave gap ranges, scrap despawn, etc.) are converted to ticks at config-load time.
Consequences: determinism, replayability, and the time-scale feature fall out for free. The simulation advances the same number of ticks over the same amount of game-time regardless of whether the game renders at 60 FPS, 30 FPS, or a stuttery mix.
Config Loading
Config files (world.toml, buildings.toml, recipes.toml, ships.toml, stations.toml) are loaded once at startup.
toml++parses the files into strongly-typed config structs.- Formula strings (e.g., threat accumulation, enemy ship level as a function of
t, per-ship stats as functions oflevel, station stats as functions of generation) are compiled once viatinyexprat load time and stored as callable objects. They are never re-parsed during simulation. - Configs are immutable after load. Any formula that fails to parse, or any required field that is missing or malformed, aborts startup with a clear error message — never mid-game.
Coordinate System
See REQ-GW-COORDS for the authoritative tile-coordinate convention. This section captures the programming-level conventions that follow from it.
- Tile coordinates are
QPoint(x, y). Origin(0, 0)is the first space tile (just right of the asteroid's right edge at game start). X grows right; Y grows down. - Asteroid tiles have
x < 0. Asteroid left-expansions add tiles at increasingly negative X; the origin never shifts, so existing tile coordinates remain stable across expansions. - Continuous world positions (ship centers, scrap drops, projectiles) use
QVector2Din tile units — one tile = 1.0 world unit. A ship center atQVector2D(-3.5, 4.0)sits at the center of the tile 3.5 tiles left of the asteroid's right edge and 4 tiles down from the top. - Rendering multiplies world units by the tile size in pixels (20) at draw time.
- Ship position always refers to the ship's center — this is the point used for sensor, attack-range, and hit-detection checks.
Core Types
Simulation types shared across subsystems:
EntityId— strictly increasing integer handle, allocated centrally by the simulation. Assigned to every targetable entity: ships, scrap drops, and buildings (including HQ and defence stations). Buildings additionally retain their anchor tile for spatial lookups and placement; theEntityIdis the canonical reference used by ship-component target fields (Weapon.currentTarget,RepairTool.currentTarget,ThreatResponse.currentTarget, etc.), so a combat ship can target either another ship or a defence station uniformly.Rotation— enum{ North, East, South, West }. The rotation applied to a building's surface_mask when placed.BuildingType— enum covering every building type in requirements.md (Miner, Smelter, Assembler, ReprocessingPlant, Shipyard, SalvageBay, Belt, Splitter, Hq, PlayerDefenceStation, EnemyDefenceStation).ItemType— tagged id of every transportable material (ores, ingots, intermediates, building_blocks, scrap).Item—struct Item { ItemType type; }. Items on belts have no persistent identity across ticks.Port—struct Port { QPoint tile; Rotation direction; }. Identifies a belt-adjacent cell and the direction of flow across that cell.MovementIntent—struct MovementIntent { int priority; QVector2D target; }. Priority follows the order declared under Movement Arbitration. Cleared at the start of each tick; the highest-priority write wins;tickMovementreads the winner.FireEvent—struct FireEvent { EntityId shooter; EntityId target; Tick emittedAt; }. Transient record emitted each time a weapon fires (REQ-SHP-FIRING, REQ-SHP-FIRING-BEAM). Buffered in a sim-owned queue and drained by the renderer; see Sim → UI Events.BlueprintDropEvent—struct BlueprintDropEvent { ShipBlueprintId blueprint; int newLevel; bool wasNewUnlock; }. Emitted when a destroyed enemy-defence-station set awards a blueprint (REQ-DEF-BLUEPRINT-DROP). The UI renders a toast (REQ-UI-BLUEPRINT-TOAST);wasNewUnlockchooses between the "unlocked" and "level → N" wording.
Sim → UI Events
The sim owns a small set of per-frame event queues that the UI drains on each render. These carry one-shot signals that are not derivable from persistent state — currently weapon fires (REQ-SHP-FIRING-BEAM) and blueprint drops (REQ-UI-BLUEPRINT-TOAST). Additional event types can be added here later (e.g., building-complete, unit-death flashes) without changing the pattern.
Implementation: a plain std::vector<FireEvent> owned by Simulation, one vector per event type. Combat resolution (tick-order step 8) appends to it. The UI calls simulation.drainFireEvents() once per rendered frame, which returns the accumulated vector by move and clears the internal one. Beams are tracked by the renderer for 0.3 s of wall time (9 ticks at 30 Hz) using the events' emittedAt tick, then discarded. If either the shooter or target entity is gone when the renderer looks them up, the beam is dropped early.
We deliberately do not use QObject signals/slots or QEvent:
- Determinism. A plain ordered vector preserves tick-order exactly; the queue is part of per-tick state, inspectable in tests.
- Sim/UI seam. The sim exposes pull-style access only; the UI never subscribes into the sim, keeping the simulation/presentation split clean.
- Headless testability. Catch2 tests read the queue directly after
tick(); no event loop, noQApplication. - Zero overhead. Sim types remain plain structs — no
QObject, no moc, no signal dispatch machinery.
If the number of event types grows past a handful, we can wrap them in a small EventQueue<T> template, still owned by the sim. Signals/slots would only be warranted if we needed multiple independent subscribers or cross-thread dispatch, and we need neither.
Tick Order
Within a single simulation tick, subsystems run in this fixed order. The order is load-bearing for determinism and for avoiding one-tick-delay artifacts (e.g., items landing on a belt but not advancing in the same tick).
- Wave scheduler — advance wave timer; on trigger, compute wave composition per REQ-WAV-TRIGGER and schedule spawn times across REQ-WAV-SPAWN-DURATION; spawn any enemy ships whose scheduled time has arrived this tick.
- Threat accumulation — add
max(0, threat_rate_formula(t))× tick_dt to threat level (REQ-WAV-THREAT-RATE). - Belt → building pull — buildings drain eligible items from adjacent belt tiles into per-material input buffers (REQ-MAT-INPUT-PORTS).
- Building production — advance production timers; start new cycles when inputs and output-buffer space permit (REQ-MAT-CYCLE); on completion, deposit output.
- Building → belt push — buildings push items from output buffer onto the belt tile at their output port (REQ-MAT-OUTPUT-PORT).
- Belt tick — advance items along belt tiles; apply splitter routing (REQ-BLD-SPLITTER).
- Ship behavior systems — clear
MovementIntenton each ship, then runtickThreatResponse,tickScrapCollector,tickRepairBehavior,tickHomeReturnin any order (arbitration is via intent priority). - Combat resolution — ships and defence stations acquire targets, fire, apply damage; queue deaths. Each fire appends a
FireEventto the sim's fire-event queue (REQ-SHP-FIRING-BEAM). - Deaths & loot — process queued deaths: drop scrap (REQ-RES-SCRAP-DROP); if a full enemy-defence-station set was destroyed this tick, award one blueprint (REQ-DEF-BLUEPRINT-DROP) and append a
BlueprintDropEvent; remove entities. tickMovement— advance ship positions based on finalMovementIntent.- Scrap despawn — decrement scrap timers; remove expired scrap (REQ-RES-SCRAP-DROP).
CMake Target Layout
Three product targets plus tests:
lib/— simulation + config. Depends on Qt Core, toml++, tinyexpr. No QtWidgets.ui/— QtWidgets +QOpenGLWidgetcode: header bar, game world view, selected building panel, build button grid. Depends onliband on Qt's OpenGL widgets module.app/— thinmain()that creates the simulation, the UI, and wires them together. Depends onui.tests/— Catch2 tests. Links only againstlib.
Directory discipline inside lib/ keeps the internal sim/config seam clear; sim code must not reach into config parsing and vice versa.
Belt Subsystem
Belts and splitters are their own specialized subsystem. Belt items are not entities — they are transient data flowing through the belt representation. They do not have identities that persist across ticks.
Public Interface
Narrow and representation-agnostic:
class BeltSystem {
public:
bool tryPutItem(Port port, Item item);
std::optional<Item> tryTakeItem(Port port);
void clearTiles(const std::vector<QPoint>& tiles); // REQ-UI-BELT-CLEAR
void tick();
void forEachVisualItem(QRect viewportTiles,
std::function<void(VisualItem)> visit) const;
};
struct VisualItem {
ItemType type;
QPointF worldPos; // in tile units, fractional
};
Buildings interact with belts only through port-level push and pull. Rendering reads only through forEachVisualItem. No other system ever asks "what is on tile X".
Implementation Strategy
- v1: per-tile representation. Each belt tile stores up to 2 items with a progress value in
[0, 1]along the tile's belt direction. Sufficient for the scale this game targets. - v2 (optional, only if v1 profiles poorly): Factorio-style belt-segment compression. Because the public interface never exposes tile-level item identity, migration is internal to the subsystem.
Rendering Note
Rendering does not cache item identities across frames. Each frame calls forEachVisualItem and paints whatever it yields. This is correct because items can merge onto the same tile, splitters can reroute, and clearTiles wipes items without warning.
If a smoother animation than the tick rate is ever needed, tick interpolation can be added inside forEachVisualItem later. Not needed initially.
Buildings
Buildings are plain structs with a fixed type determined at construction.
struct Building {
EntityId id;
QPoint tile;
QSize footprint;
Rotation rotation;
BuildingType type;
float hp; // relevant for HQ and defence stations; ignored otherwise.
float maxHp;
InputBuffer inputBuffer;
OutputBuffer outputBuffer;
// Production timer, the recipe currently running, and — for reprocessing
// plants — the output item picked at cycle start (REQ-MAT-CYCLE).
std::optional<Production> production;
};
- The uniform "input buffer → production timer → output buffer" pattern across miner, smelter, assembler, reprocessing plant, and shipyard is driven by the recipe config, not by a class hierarchy.
- Belts and splitters are separate types owned by the belt subsystem, not general
Buildinginstances. - No ECS for buildings. A miner is never also an assembler; there is no composition benefit to decomposing buildings into components.
Scrap
Scrap is the only non-ship, non-building entity in the simulation:
struct Scrap {
EntityId id;
QVector2D position; // world units, tile-fractional; ship-center convention
int amount;
Tick despawnAt; // absolute tick at which the scrap is removed
};
Created in tick step 9 (Deaths & loot) per REQ-RES-SCRAP-DROP, consumed by salvage ships in tick step 7 (ScrapCollector), and removed in tick step 11 when the current tick reaches despawnAt.
Ships
Ships follow a component-composition model using std::optional<Component> members. Each orthogonal capability is a component; each behavior is also a component, ticked by its own system. A ship's "role" is just which components it has — not a class or an enum.
Capability Components
struct Weapon { float damage; float range; float fireRateHz; float cooldownTicks;
std::optional<EntityId> currentTarget; };
struct SalvageCargo { int capacity; int current; };
struct RepairTool { float ratePerTick; std::optional<EntityId> currentTarget; };
Behavior Components
Behaviors are decomposed, not bundled into per-role monolithic AIs. This is the critical modeling choice: adding a capability (e.g., putting a Weapon on a repair ship) must not require rewriting AI code.
struct ThreatResponse { float engagementRange; CombatStance stance;
CombatTargetPriority priority;
std::optional<EntityId> currentTarget; };
struct ScrapCollector { std::optional<QVector2D> scrapTarget; EntityId deliveryBay; };
struct RepairBehavior { RepairTargetPriority priority;
std::optional<EntityId> currentTarget; };
struct HomeReturn { float retreatHpFraction; QVector2D homePos; };
Ship
struct Ship {
EntityId id;
QVector2D position;
QVector2D velocity;
float hp;
float maxHp;
int level;
ShipBlueprintId blueprint;
// Capabilities
std::optional<Weapon> weapon;
std::optional<SalvageCargo> cargo;
std::optional<RepairTool> repairTool;
// Behaviors
std::optional<ThreatResponse> threatResponse;
std::optional<ScrapCollector> scrapCollector;
std::optional<RepairBehavior> repairBehavior;
std::optional<HomeReturn> homeReturn;
// Written by behavior systems, read by movement.
MovementIntent intent;
};
Systems
Each behavior has its own tick system. A system iterates a flat std::vector<Ship> and skips ships that do not have the relevant components.
tickThreatResponse— requiresthreatResponse+weapon. Acquires target, fires, manages cooldown.tickScrapCollector— requiresscrapCollector+cargo. Flies to scrap, picks up, returns to delivery bay.tickRepairBehavior— requiresrepairBehavior+repairTool. Finds damaged target, moves to range, repairs.tickHomeReturn— requireshomeReturn. Overrides movement if hp drops below threshold.tickMovement— readsintent, advancesposition.
Movement Arbitration
When multiple behaviors want to drive movement, a fixed global priority resolves the conflict. Each behavior system writes a MovementIntent carrying its priority; a higher-priority write overwrites a lower-priority one. tickMovement reads the final winner.
Initial priority order (subject to tuning):
HomeReturn > ThreatResponse > RepairBehavior > ScrapCollector
tickMovement runs last. Intents are cleared at the start of each tick.
Why Not ECS
EnTT would be a reasonable fit for ships specifically, but at the scale of this game (low hundreds of ships) the iteration-speed benefit is not decisive. The std::optional<Component> pattern gives the same modeling expressiveness with zero dependencies and no learning curve. Migration to EnTT is mechanical (std::optional<Weapon> weapon becomes registry.emplace<Weapon>(entity, ...)) if it ever becomes warranted.
Buildings and the belt subsystem stay outside any entity model regardless of what ships do — they are the wrong shape for ECS.
Rendering
The game world is rendered by a single GameWorldView widget that inherits QOpenGLWidget and uses QPainter for all drawing. This gives the same imperative paint API as a plain QWidget with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization.
Render Loop
- A
QTimerinGameWorldViewfires at 60 Hz and callsupdate(), requesting a repaint. Render rate is fixed at 60 FPS regardless of game speed. - The sim advances independently via an accumulator-based driver:
- Each frame, compute
accumulator += elapsedWallMs * gameSpeedMultiplier. - While
accumulator >= tickDurationMs(= 1000/30 ≈ 33.33 ms), advance the sim by one tick and subtract. - 0× clamps the multiplier to 0 (pause). 0.5× / 2× / 4× scale accumulation directly.
- Each frame, compute
- This decouples render rate (60 FPS) from sim rate (30 Hz, see Fixed-Timestep Tick-Based Simulation above) and keeps all game speeds correct across variable frame timing.
Threading
Sim and UI run on the same thread for v1. paintEvent reads sim state directly without locks. If profiling later justifies moving the sim to a worker thread, the pull-style drainFireEvents() / drainBlueprintDropEvents() / forEachVisualItem() APIs already support a clean snapshot-and-render split; a single mutex at the sim boundary would suffice.
Layer Order (back to front)
- Tile background — asteroid tiles and space tiles within the viewport.
- Buildings — factory buildings, HQ, player and enemy defence stations.
- Belt items — 10×10 colored squares emitted by
BeltSystem::forEachVisualItem. - Scrap — glyphs at world positions.
- Ships — colored arrows oriented by velocity; color keyed to role (player combat / salvage / repair / enemy).
- Laser beams — lines derived from live
FireEvents kept by the renderer for 0.3 s (REQ-SHP-FIRING-BEAM). - Build overlays — ghost in builder mode (REQ-BLD-GHOST), demolish-mode tint, tile highlight under cursor, box-drag selection rectangle.
- Screen-space UI — blueprint toasts (REQ-UI-BLUEPRINT-TOAST) and any other screen-anchored elements, drawn after resetting the world-space transform.
Coordinates and Scrolling
GameWorldViewholds a continuousscrollXTiles(float). A / D input pans this smoothly (REQ-UI-SCROLL).- At the start of
paintEvent, a singlepainter.translate(-scrollXTiles * tilePx, 0)maps world tile units into widget pixels (tilePx = 20, per REQ-GW-TILE-SIZE). - Mouse input converts the other way:
worldX = mouseX / tilePx + scrollXTiles; applyfloorfor a tile. Asteroid tiles (x < 0) need no special casing — they share the coordinate system with space tiles.
Culling
The renderer iterates only entities and tiles whose world X lies within the visible viewport. In particular, everything at or past the rightmost enemy defence station (i.e., the current enemy buffer zone) is culled — consistent with REQ-GW-SCROLL-LIMIT.
Visual Parameters
Shapes are hardcoded in the renderer — a building is a rectangle per footprint tile, a ship is an oriented arrow/triangle, a belt item is a 10×10 square, scrap is a small circle, a beam is a line. These structural choices live in the draw<X>(painter, entity) functions of the UI and are not expected to change frequently.
Colors, outline widths, glyph text, and tile tints live in a separate config file, visuals.toml, loaded once by the UI at startup using the same pattern and lifetime as the sim config files (see Config Loading). The file is UI-scoped: the sim does not read it and does not depend on it.
Sketch of visuals.toml:
[tiles]
asteroid = { fill = "#4a4038" }
space = { fill = "#0a0a15" }
[buildings.miner]
fill = "#6b4a2c"
outline = "#ffffff"
glyph = "M"
# ... one [buildings.<type>] section per BuildingType
# ... one [stations.<player|enemy>] section
# ... one [items.<item_type>] section per ItemType
[ships.player_combat] { fill = "#3366ff", outline = "#ffffff" }
[ships.salvage] { fill = "#33cc66", outline = "#ffffff" }
[ships.repair] { fill = "#66ccff", outline = "#ffffff" }
[ships.enemy] { fill = "#cc3333", outline = "#ffffff" }
[beams]
color = "#ff6600"
width_px = 2
[overlays]
ghost_valid = "#ffffff44"
ghost_invalid = "#ff000044"
demolish_tint = "#ff000033"
selection_rect = "#00ff00"
[toast]
bg = "#000000cc"
fg = "#ffffff"
font_size = 14
Key names mirror sim identifiers (buildings.miner ↔ BuildingType::Miner, items.<x> ↔ ItemType). The UI builds a lookup indexed by the sim's enum or string id; a missing or malformed entry aborts startup with a clear error, same as sim configs. Adding a new BuildingType or ItemType to the sim requires adding a matching visuals.toml entry — the fail-on-missing rule catches the omission at startup rather than silently rendering invisible entities.
Animation
v1 uses no sprite atlases, no anti-aliasing, and no tick-to-tick interpolation (ships snap to their 30 Hz positions). This is not an architectural constraint — sprite atlases, AA, and interpolation are all incremental upgrades behind the same draw<X> functions and the visuals.toml schema.
Testing
- Catch2 tests link against
libonly. No QApplication, no display. - The tick-based, deterministic simulation is directly testable: construct a world, step N ticks, assert state.
- Config loading is tested with small fixture TOML files.
- Formula parsing failures must be covered (malformed input produces a clear error, does not silently default).