Files
dota_factory/docs/architecture.md

9.7 KiB
Raw Blame History

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, QVector3D, 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 (30 or 60 Hz, TBD; choose once and document here).
  • The outer loop advances N ticks per wall-clock frame, where N is derived from the selected game speed:
    • 0× → 0 ticks/frame (pause)
    • 0.5× → one tick every two frames
    • 1× → one tick/frame
    • 2× → two ticks/frame
    • 4× → four ticks/frame
  • 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 falls out for free.

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.

CMake Target Layout

Three product targets plus tests:

  • lib/ — simulation + config. Depends on Qt Core, toml++, tinyexpr. No QtWidgets.
  • ui/ — QtWidgets code: header bar, game world view, selected building panel, build button grid. Depends on lib.
  • 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.

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 {
    QPoint tile;
    QSize footprint;
    Rotation rotation;
    BuildingType type;
    InputBuffer inputBuffer;
    OutputBuffer outputBuffer;
    // Production timer and current recipe, where applicable.
    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.

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<QVector3D> scrapTarget; EntityId deliveryBay; };
struct RepairBehavior  { RepairTargetPriority priority;
                         std::optional<EntityId> currentTarget; };
struct HomeReturn      { float retreatHpFraction; QVector3D homePos; };

Ship

struct Ship {
    EntityId id;
    QVector3D position;
    QVector3D 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 — requires threatResponse + weapon. Acquires target, fires, manages cooldown.
  • tickScrapCollector — requires scrapCollector + cargo. Flies to scrap, picks up, returns to delivery bay.
  • tickRepairBehavior — requires repairBehavior + repairTool. Finds damaged target, moves to range, repairs.
  • tickHomeReturn — requires homeReturn. Overrides movement if hp drops below threshold.
  • tickMovement — reads intent, advances position.

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.

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