# 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 and Qt Gui (QPoint, QVector2D, QRect, etc., as required by the coding guidelines), toml++, and tinyexpr. It contains no QtWidgets, no painting, and no QApplication. Note: in Qt 5, vector math types such as QVector2D live in Qt::Gui rather than Qt::Core, so the lib links both.
- 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, deconstruct, 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 × gameSpeedMultiplier` to an accumulator and flushes one `tick()` per `tickDurationMs` of 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, debris 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 of `level`, station stats as functions of generation) are compiled once via `tinyexpr` at 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.
- The UI layer loads its own TOML file, `visuals.toml`, using the same toml++-based pattern and the same immutability / fail-fast rule (see Rendering → Visual Parameters). The simulation never reads it.

## 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, debris, projectiles) use `QVector2D` in tile units — one tile = 1.0 world unit. A ship center at `QVector2D(-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, debris, **and** buildings (including HQ and defence stations). Buildings additionally retain their anchor tile for spatial lookups and placement; the `EntityId` is the canonical reference used by ship-component target fields (`Weapon.currentTarget`, `RepairTool.currentTarget`, `AttackBehavior.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). `Belt` and `Splitter` share the enum for cost, construction, placement, and `visuals.toml` lookup, but their runtime data lives inside the belt subsystem rather than in `Building` instances (see Belt Subsystem).
- `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 { bool active; QVector2D target; }`. Written by the winning behavior's executor (see Movement Arbitration). Cleared (`active = false`) at the start of each tick; `tickMovement` brakes when inactive, otherwise drives toward `target`.
- `BeamFiredEvent` — `struct BeamFiredEvent : public Event { BeamKind kind; entt::entity shooter; entt::entity target; Tick emittedAt; }`. Transient record emitted each time a weapon fires, a repair tool starts a heal cycle, or a salvage module starts a collection cycle (REQ-SHP-FIRING, REQ-SHP-FIRING-BEAM). `BeamKind` (`Weapon`/`Repair`/`Salvage`) selects the beam color. Buffered in a sim-owned vector during the tick, then drained and re-emitted via EventManager by the UI frame handler; see Sim → UI Events.
- `SchematicChoiceOption` — `struct SchematicChoiceOption { string schematicId; SchematicType type; string displayName; bool isNewUnlock; int targetLevel; }`. Describes one option in the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP). Up to three are generated when an enemy station set is destroyed. `SchematicType` is `Ship`, `Module`, or `Recipe`.
- `SchematicChoicesAvailableEvent` — EventManager event carrying a `vector<SchematicChoiceOption>`. Sent by the UI each frame when pending choices are detected; handled by `MainWindow` which opens the schematic choice dialog.

## Event System

All inter-component communication — both sim→UI and UI→UI — uses a unified `EventManager`/`EventHandler` system. No custom Qt signals/slots are used for inter-widget communication.

### EventManager

`EventManager` is a singleton (`EventManager::getInstance()`) that routes events to registered handlers.

- `sendEventImmediately(shared_ptr<Event>)` — synchronous dispatch to all handlers of the event's type.
- `addEvent(shared_ptr<Event>)` — queues the event for later batch processing.
- `processEvents()` — drains the queue, dispatching each event to its handlers.

The EventManager is thread-safe (mutex-guarded).

### EventHandler

`EventHandler<T>` is a CRTP-style template that a class inherits to receive events of type `T`. It provides `registerForEvent()` / `unregisterForEvent()` and requires an override of `handleEvent(shared_ptr<const T>)`.

`CombinedEventHandler<Ts...>` is a variadic template for classes that handle multiple event types. It provides `registerForEvents()` / `unregisterForEvents()` and requires one `handleEvent` override per type.

### Sim → UI Events

The simulation layer stays free of EventManager — it uses a plain `std::vector<BeamFiredEvent>` internally (owned by `Simulation`, filled by the combat, repair, and salvage systems). This preserves determinism, tick-order fidelity, and headless testability (Catch2 tests read the queue directly via `drainBeamFiredEvents()` after `tick()`).

The UI frame handler (`GameWorldView::onFrame` / `ArenaView::onFrame`) bridges the gap: each frame it calls `simulation.drainBeamFiredEvents()`, then re-emits each `BeamFiredEvent` via `EventManager::sendEventImmediately()`. Subscribers (the same view's `handleEvent(BeamFiredEvent)`) create `ActiveBeam` records tracked for 0.3 s of wall time, then discarded. If either the shooter or target entity is gone when the renderer looks them up, the beam is dropped early.

Schematic drops: when an enemy station set is destroyed, the simulation generates up to 3 `SchematicChoiceOption` entries and stores them as pending state. The UI polls `hasSchematicChoicesPending()` each frame and, when true, sends a `SchematicChoicesAvailableEvent` via EventManager. `MainWindow` handles this event by pausing the game and opening a modal `SchematicChoiceDialog`. The player's selection is fed back via `applySchematicChoice(index)`.

### UI Events

All UI interactions — building selection, builder/blueprint mode transitions, speed changes, deconstruct mode, escape menu, layout dialog requests — are communicated via EventManager events rather than Qt signals/slots. Each event is a small struct inheriting `Event` (e.g., `SelectionChangedEvent`, `BuildingTypeSelectedEvent`, `SpeedChangeRequestedEvent`). Widgets register as `CombinedEventHandler` for the events they care about and emit events via `EventManager::sendEventImmediately()`.

Bidirectional interactions use separate request/notification event types to avoid infinite recursion (e.g., `ExitBuilderModeRequestedEvent` from `BuildButtonBar` → `GameWorldView`, vs. `BuilderModeExitedEvent` from `GameWorldView` → `BuildButtonBar`).

### Reading Simulation State

The simulation is the single source of truth for every game value (building block stock, expansion cost, threat level, tick, etc.). A UI widget that needs such a value holds the `Simulation*` it was constructed with and **pulls the value on demand** via the corresponding getter (e.g., `m_sim->getBuildingBlocksStock()`), rather than caching its own copy.

State-change events (e.g., `BuildingBlocksChangedEvent`) are treated as *refresh signals*, not as carriers of truth: a widget subscribes to the event to learn *when* the value changed and then re-reads it from the simulation to learn *what* it now is. The value carried in the event payload is not authoritative and should not be stored. This keeps a single copy of each value and avoids stale-cache bugs (a widget acting on a value that has since moved on because nothing refreshed its local copy).

## 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).

1. **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.
2. **Threat accumulation** — add `max(0, threat_rate_formula(t))` × tick_dt to threat level (REQ-WAV-THREAT-RATE).
3. **Belt → building pull** — buildings drain eligible items from adjacent belt tiles into per-material input buffers (REQ-MAT-INPUT-PORTS).
4. **Building production** — advance production timers; start new cycles when inputs and output-buffer space permit (REQ-MAT-CYCLE); on completion, deposit output.
5. **Building → belt push** — buildings push items from output buffer onto the belt tile at their output port (REQ-MAT-OUTPUT-PORT).
6. **Belt tick** — advance items along belt tiles; apply splitter routing (REQ-BLD-SPLITTER).
7. **Ship behavior systems** — clear `MovementIntent` on each ship, then the `AiSystem` runs three batched phases: every behavior **evaluator** scores its behavior and sets its target data; a **selection** pass records the highest-scoring behavior per ship in `SelectedBehaviorComponent`; each behavior **executor** runs for the winner, writing `MovementIntent` and preferred module targets. The module systems then perform world mutation: `SalvagerSystem` (scrap collection/delivery) and `RepairSystem` (healing). See Movement Arbitration.
8. **Combat resolution** — ships and defence stations validate/acquire targets, fire, apply damage; queue deaths. Each fire appends a `BeamFiredEvent` to the sim's beam-fired-event queue (REQ-SHP-FIRING-BEAM). The repair and salvage module systems (tick step 7d) append their own `BeamFiredEvent`s to the same queue when they start a cycle.
9. **Deaths & loot** — process queued deaths: drop debris (REQ-RES-DEBRIS-DROP); if a full enemy-defence-station set was destroyed this tick, generate up to 3 schematic choice options (REQ-DEF-SCHEMATIC-DROP) stored as pending state for the UI to present; remove entities.
10. **`tickMovement`** — advance ship positions based on final `MovementIntent`.
11. **Debris despawn** — decrement debris timers; remove expired debris (REQ-RES-DEBRIS-DROP).

## CMake Target Layout

Three product targets plus tests:

- `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets.
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selection panel, build button bar, controls panel. Depends on `lib` and on Qt's OpenGL widgets module.
  - `ui/selection/` — the selection panel's contents. `SelectionPanel` itself only arbitrates between the two selection categories, picks a card from the catalog (`SelectionContentFactory`), and hosts one at a time; each kind of selection has its own `SelectionContent` subclass assembled from shared parts (REQ-UI-SELECTION-CARD, REQ-UI-SELECTION-CONTENT).
- `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`.
- `tests/` — Catch2 tests. Links only against `lib`.

Directory discipline inside `lib/` keeps the internal sim/config seam clear; sim code must not reach into config parsing and vice versa.

## Player Input

Every player control is declared once, in `lib/core/ControlAction.h`, and read by three consumers that must never disagree about it:

* **`ControlsPanel`** asks which actions apply and draws a row per action (REQ-UI-CONTROLS-CONTENT).
* **`InputMapper`** resolves a key press to an action and fires the event that action stands for.
* **`GameWorldView`** resolves a mouse gesture to an action and runs the branch that carries it out.

The file declares; it never performs. It holds no simulation access, fires no events, and names nothing — display strings live in `ui/ControlActionText.h`, which renders each badge from the binding the resolver actually matches, so a chip cannot claim a key that does nothing. What an action *does* stays in the widget that always did it: the drag state machines, hit-testing, and command enqueuing were not moved.

Three invariants are easy to break here:

* **Do not add a shortcut straight to `InputMapper`'s switch or `mousePressEvent`'s branches.** Add the action and its binding to the table; the handler switches on the resolved action. A binding added directly is invisible to the panel, which is the drift the table exists to prevent. (Build hotkeys and `F3`/`F4` are deliberate exceptions, documented in the header and in REQ-UI-CONTROLS-ACCURACY.)
* **Availability and display are different questions.** An action can be live in a context the panel does not advertise it in — `Ctrl`+click with an empty selection is the standing example. `isControlActionAvailable` answers the first, the per-context row lists answer the second, and `ControlActionTest` asserts the pairing that matters: every row's bindings resolve back to that row's action.
* **Gesture state is shared, not owned by an action.** Whether a belt drag is in progress decides what the right mouse button means, so it lives on `BuildModeController` where the resolver can see it — as does the hovered-transfer flag. `m_boxSelecting` is likewise one gesture serving two actions (box select and deconstruct area).

`ControlContext` is a plain snapshot rather than references to the live controllers, which is what keeps the rules testable without a world and stops an action reaching into the simulation: if a rule needs a fact, the fact is named in the struct and the caller supplies it. When bindings become player-configurable, only the binding tables in `ControlAction.cpp` turn from hard-coded data into loaded data.

## 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

`BeltSystem.h` is authoritative. The surface is wider than the original design sketch — 15 public methods in five groups, not the 5-method port interface this section used to describe:

```cpp
class BeltSystem {
public:
    // Placement — belts/splitters/tunnels are Buildings for cost and
    // construction, so BuildingSystem registers and unregisters their tiles.
    void placeBelt(QPoint tile, Rotation direction);
    void placeTunnelEntry(QPoint tile, Rotation direction, int maxDistance);
    void placeTunnelExit(QPoint tile, Rotation direction);
    void placeSplitter(QPoint tile, Rotation outputA, Rotation outputB);
    void removeTile(QPoint tile);

    // Splitter filter configuration (REQ-BLD-SPLITTER). A splitter's filters
    // live here, not on Building, so callers that re-register a tile must
    // carry them across (see BuildingSystem::reregisterBeltTile).
    void setSplitterFilters(QPoint tile, const std::vector<ItemType>& filterA,
                                         const std::vector<ItemType>& filterB);
    std::optional<SplitterInfo> getSplitterInfo(QPoint tile) const;

    // Port interface (buildings <-> belts)
    bool tryPutItem(QPoint tile, Item item, Rotation fromDir = Rotation::West);
    std::optional<Item>     tryTakeItem(Port port);
    std::optional<ItemType> peekItem(Port port) const;
    double getProgressPerTick_tpt() const;   // shared so building output items
                                             // travel at belt speed (REQ-MAT-OUTPUT-EMERGE)

    // Maintenance
    void clearTiles(const std::vector<QPoint>& tiles);   // REQ-UI-BELT-CLEAR
    void tick();

    // Rendering
    void forEachVisualItem(QRect viewportTiles,
                           std::function<void(VisualItem)> visit) const;

    // Determinism (docs/replay_design.md)
    void appendChecksum(Hasher& hasher) const;
};

struct VisualItem {
    ItemType type;
    QPointF worldPos;   // in tile units, fractional
};
```

Item *transport* is still reached only through push and pull: `tryPutItem` / `tryTakeItem` move items, `peekItem` reveals the leading item's type but never an identity, and rendering reads only through `forEachVisualItem`. The growth is in tile **topology** — placement, removal and splitter filters — which `BuildingSystem` drives because belts are `Building`s for cost, construction and deconstruction. That coupling is real and is not going away.

### 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. The migration argument still holds for the item representation, since no method exposes tile-level item identity — but a v2 would have to keep the placement and splitter-filter methods working per tile, which is a stronger constraint than this section originally implied.

### 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.

```cpp
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 `Building` instances.
- No ECS for buildings. A miner is never also an assembler; there is no composition benefit to decomposing buildings into components.

## Debris

Debris — the salvageable object dropped by destroyed ships and defence stations — is the
only non-ship, non-building entity in the simulation. Each piece carries a scrap amount:

```cpp
struct Debris {
    EntityId id;
    QVector2D position;     // world units, tile-fractional; ship-center convention
    int amount;             // scrap the piece still holds
    Tick despawnAt;         // absolute tick at which the debris is removed
};
```

Created in tick step 9 (Deaths & loot) per REQ-RES-DEBRIS-DROP, drained one scrap per cycle by salvage ships in tick step 7 (SalvagerSystem), 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

```cpp
struct Weapon       { float damage; float range; float fireRateHz; float cooldownTicks;
                      std::optional<EntityId> currentTarget; };
struct SalvageCargo { int capacity; int current; };
struct RepairTool   { float repairAmountHp; int repairIntervalTicks; int cooldownTicksRemaining;
                      float range; 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. Each behavior is a small component carrying its own target data plus a `float score` written by its evaluator each tick.

```cpp
struct AdvanceBehavior      { float score; };                                   // baseline fallback, all ships
struct RallyBehavior        { QVector2D rallyPoint; float score; };             // player combat ships
struct RetreatBehavior      { float retreatHpFraction; QVector2D retreatPoint;  // player ships
                              float score; };
struct AttackBehavior       { std::optional<EntityId> currentTarget; float score; };
struct RepairBehavior       { std::optional<EntityId> currentTarget;
                              float maxRepairRange_tiles; float score; };
struct SalvageScrapBehavior { std::optional<QVector2D> debrisTarget;
                              float maxCollectionRange_tiles; float score; };
struct DeliverScrapBehavior { BuildingId deliveryBay; float score; };
struct SelectedBehaviorComponent { BehaviorKind winner; float bestScore; };     // selection result
```

### Ship

```cpp
struct Ship {
    EntityId id;
    QVector2D position;
    QVector2D velocity;
    float hp;
    float maxHp;
    int level;
    ShipSchematicId schematic;

    // Capabilities
    std::optional<Weapon>       weapon;
    std::optional<SalvageCargo> cargo;
    std::optional<RepairTool>   repairTool;

    // Behaviors (attached per capability; AdvanceBehavior + SelectedBehaviorComponent
    // on every ship, RetreatBehavior on player ships, etc.)
    std::optional<AttackBehavior>       attackBehavior;
    std::optional<SalvageScrapBehavior> salvageScrapBehavior;
    std::optional<DeliverScrapBehavior> deliverScrapBehavior;
    std::optional<RepairBehavior>       repairBehavior;

    // Written by the winning behavior's executor, read by movement.
    MovementIntent intent;
};
```

### Systems

Each behavior is split into a stateless **evaluator** and **executor** class (one per behavior, e.g. `AttackEvaluator`/`AttackExecutor`), orchestrated by `AiSystem`. Evaluators and executors only read/write behavior components and module target fields — they never mutate the game world. World mutation lives in dedicated module systems that run every tick, independent of which behavior won:

- `CombatSystem` — validates each weapon's executor-set target, falls back to nearest-target acquisition, fires, applies damage.
- `SalvagerSystem` — collects scrap into cargo and delivers full cargo at a `SalvageBay`.
- `RepairSystem` — validates each repair tool's target, falls back to nearest damaged friendly, applies healing.
- `MovementIntentSystem` (`tickMovement`) — reads `MovementIntent`, advances `position`; brakes when inactive.

### Movement Arbitration

Arbitration is **score-based**, not fixed-priority. In a single tick `AiSystem` runs three phases:

1. **Evaluate** — every behavior's evaluator iterates the ships that have its component, sets its target data, and writes a `float score` (see `BehaviorScores.h`). An evaluator returns an inactive score when its behavior does not apply.
2. **Select** — `selectWinningBehaviors` resets each `SelectedBehaviorComponent`, then compares every behavior's score per ship, recording the highest as `winner`. Behaviors are considered highest-band first so a strict `>` breaks ties toward the more urgent behavior.
3. **Execute** — each behavior's executor runs only for ships where it is the `winner`, writing the single `MovementIntent` and any preferred module targets.

`AdvanceBehavior` is present on every ship with the lowest score, guaranteeing a winner. The resulting band order:

```
Retreat > Attack / Repair / SalvageScrap / DeliverScrap > Rally > Advance
```

`MovementIntent` is cleared (inactive) at the start of each tick; `tickMovement` runs last.

### 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 drawn into 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.

The drawing itself lives in `WorldRenderer`, not in the widget. `paintGL` is a call sequence: build the frame's `WorldCoordinates`, hand the renderer a `WorldRenderFrame`, then draw the screen-anchored chrome. The split is the world-space / screen-space line, and it is exact: the renderer draws everything positioned in tiles, while everything positioned in pixels — the pause and deconstruct vignettes, the replay overlay, the debug stats panel — stays with the widget. A useful consequence is that the renderer draws no translatable text at all (its text is config-driven glyphs, ASCII port arrows, and numbers), so it needs no `tr()` and no tie to the meta-object system.

`WorldRenderFrame` is what makes the renderer independent of the widget. The renderer reads the simulation directly, but everything else it draws is interaction state the widget owns — the selection, the active build mode, live beams, the box-select rectangle. Those are gathered into the frame each `paintGL` and passed by reference, so the renderer keeps no copy that a later click could invalidate. The renderer knows nothing about input: the widget resolves clicks and hit-tests, and the renderer only draws the result.

### Render Loop

- A `QTimer` in `GameWorldView` fires at 60 Hz and calls `update()`, 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.
- 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 `drainBeamFiredEvents()` / `getPendingSchematicChoices()` / `applySchematicChoice()` / `forEachVisualItem()` APIs already support a clean snapshot-and-render split; a single mutex at the sim boundary would suffice. The `ArenaSimulation` used by the balancing tool runs headlessly on a worker thread; fire events accumulate in its internal vector and are only drained when `ArenaView` drives `tickOnce()` on the main thread during interactive inspection.

### Layer Order (back to front)

1. **Tile background** — asteroid tiles and space tiles within the viewport.
2. **Buildings** — factory buildings, HQ, player and enemy defence stations.
3. **Belt items** — 10×10 colored squares emitted by `BeltSystem::forEachVisualItem`.
4. **Scrap** — glyphs at world positions.
5. **Ships** — colored arrows oriented by velocity; color keyed to role (player combat / salvage / repair / enemy).
6. **Laser beams** — lines derived from live `BeamFiredEvent`s kept by the renderer for 0.3 s, colored per `BeamKind` (weapon/repair/salvage) (REQ-SHP-FIRING-BEAM).
7. **Build overlays** — ghost in builder mode (REQ-BLD-GHOST), deconstruct-mode tint, tile highlight under cursor, box-drag selection rectangle.
8. **Screen-space UI** — screen-anchored elements, drawn after resetting the world-space transform.

### Coordinates and Scrolling

- The horizontal view position lives in `WorldCamera` (`lib/core/`) as a continuous view-center X in tiles. A / D input pans it smoothly (REQ-UI-SCROLL) at a position-dependent speed (REQ-UI-SCROLL-SPEED). The camera works purely in world units — tiles and tiles/second, never pixels — which is what keeps it independent of `WorldCoordinates`; the two meet only where `GameWorldView` feeds `getViewCenterXTiles()` into the transform.
- The camera takes no simulation dependency. Its pan limits move with asteroid expansion and with pushes, so `GameWorldView` reads them from the sim each frame and passes them in as `ScrollBounds`; the camera clamps on every `advance()`, not only when panning, so the view follows the bounds inward when they shrink. Pan *intent* is likewise passed in as a `PanDirection` rather than read from key state, so the camera is unaffected if controls later become rebindable. Both properties are what make it a plain value with unit tests (`WorldCameraTest`) — notably over the two-ramp pan-speed curve, whose overlapping-band and zero-width-band cases are otherwise easy to break unnoticed.
- The world↔widget transform itself lives in `WorldCoordinates` (`lib/core/`), not in the view. It is an immutable value, built through one of two named factories that differ only in how `tilePx` and the left edge are derived; everything downstream is shared. `scrolling(...)` is the game world: `tilePx` makes the world height fill the viewport (REQ-GW-TILE-SIZE) and the view pans horizontally. `fitToWorld(...)` is the balancing tool's arena: a fixed world shown whole, so `tilePx` is the tighter of the two axis fits and there is no scroll. Being a plain value with no Qt Widgets dependency, it is unit-tested (`WorldCoordinatesTest`) even though the widgets around it are not.
- `GameWorldView::getCoordinates()` and `ArenaView::getCoordinates()` each build one per frame in `paintGL` and per event in the mouse handlers, and pass it down: every world-space `draw<X>` takes a `const WorldCoordinates&`, while the screen-space draws (vignette borders, replay overlay, debug text) take none. The snapshot is deliberately never cached in a member — a resize or a scroll would silently invalidate it.
- Conversions are per-call arithmetic rather than a `painter.translate`, because hit-testing needs the inverse (`widgetToWorld` / `widgetToTile`, flooring for a tile) as often as drawing needs the forward direction. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles, which is why the flooring must not be truncation.

### 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.

The few shapes the game view and the balancing tool's arena view draw *identically* — the ship body, the health bar, the debris marker, the sensor-range circle — live in `ui/WorldPrimitives` as free functions over explicit values. The arena exists to eyeball combat, so it only works while a ship there looks like a ship in the game; keeping these in one place means a retuned ship shape cannot silently stop applying to the tool that measures it. The balancing target does not link the `ui` library, so it compiles that file into itself, the same way it already does for `VisualsLoader` and `ShipStatsPanel` (see `balancing/CMakeLists.txt`). Everything the two views draw differently — selection highlights, beams, target lines, and all of the factory — stays with each view; the shared set is deliberately not grown beyond shapes that are genuinely the same.

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`:

```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"
deconstruct_tint  = "#ff000033"
selected_outline = "#ffff00"

[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 `lib` only. 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).
