Tactical Action · Features

The RTS (StarCraft & AoE-Style) Cell

A focused page within the Tactical Action Features documentation. The full map and every sibling page live in the Features hub.

6sections13 minread1diagram

On this page

This is V4's strategy game. The RTS cell is the top-down, click-to-command surface where the loop is gather, build, tech, expand, and macro an army into a fight — the genre where the camera floats above the battlefield, two resources drive every decision, and a match is won by an economy three minutes before it is won by a battle. It cares enormously whether two machines re-running the same commands arrive at the same world, because RTS networking ships only inputs and trusts every client to simulate the rest. V4 builds two macro games on that single foundation: a StarCraft-style contest of three asymmetric factions on a competitive ladder, and an Age-of-Empires-style historical game of twelve civilizations climbing five ages toward a Wonder. Both feed one deterministic C++ simulation (V4RTS) and one lockstep transport (V4Netcode). For the full mode taxonomy, the roster, and the scope this cell slots into, start at the hub: ../V4_features.md.

What ships, honestly#

The RTS simulation core is real, compiled, and pinned by domain-correct tests. The cell module V4/ue/Source/V4RTS carries 11 .cpp / 11 .h of genre logic — a 32.32 fixed-point number type, a seeded A* pathfinder that self-audits its own determinism, an Ore/Crystal economy, a grid-footprint builder, a timed production queue, a prerequisite-gated tech tree, an age-up ladder, a three-state fog grid, control groups, and a Wonder-victory timer — wired into GAS through four RTS-gated abilities in V4RTSAbilities. The numbers are domain-specific, not placeholders: an 8-per-second gather rate, a 10-s build and 5-s train, a population-scaled age-up cost, a 420-second (seven-minute) Wonder defense. Three RTS specs under V4/ue/Source/V4Tests/Private/V4RTSTests (EconomySpec, PathSpec, FogSpec) plus the per-plugin AsymmetricRTSSpec and HistoricalRTSSpec assert computed values — a gather of exactly 48, a Manhattan heuristic of exactly 7, a radius-one reveal of exactly 5 cells — the kind of check that fails instantly against a hardcoded return.

Three honest qualifications, in the spirit of the architecture companion. First, V4 has zero cooked .uasset content: factions, units, civs, tech tiers, build orders, and maps are data definitions and JSON-described content, not authored levels or meshes. UV4AsymmetricRTSCatalog::BuildMaps() returns twenty FV4AsymRTSMapDefinition rows with player slots and team sizes — a real, validated map manifest, not twenty playable terrains. Second, the cell is a library of individually-tested components and subsystems, not yet a single running match. Each spec exercises a piece in isolation (NewObject<> then drive it); there is no in-tree "RTS GameMode actor" that composes resource → build → train → fog → pathfinding → lockstep into one ticking loop, and unit combat resolution is the thinnest partUGA_AttackMove assigns a control group, trained units arrive as gameplay tags, and per-unit HP/targeting leans on the shared GAS attributes rather than a bespoke RTS battle simulator. Third, the lockstep layer is a deterministic library, not a wired net driver (no FSocket, no replicated UPROPERTY), and it hashes the input history, config, and seed — folding the RTS sim's own unit positions into that digest is V4RTS's job and is not yet wired or tested. The honest netcode accounting lives in ../architecture/networking-determinism.md; what follows is the strategy game on its own terms.

The RTS experience — two macro games on one sim#

Strip the genre to its verbs and the shared loop is small: send workers to a resource, spend it to raise buildings, queue units out of those buildings, research the tech that unlocks better units, push the fog back with scouts, and trade armies until one economy collapses. V4 ships that loop twice, as two GameFeatures plugins on top of the same V4RTS core.

StarCraft-style — three asymmetric factions and a ladder#

V4Mode_RTS_Asymmetric is the micro-heavy, build-order-driven competitive game. UV4AsymmetricRTSCatalog::BuildFactions() (V4/ue/Plugins/V4Mode_RTS_Asymmetric/.../V4AsymmetricRTSSystems.cpp:108) authors three deliberately asymmetric factions, original IP standing in for the Terran/Zerg/Protoss design space: Vanguard (an industrial mech faction with MobileBases, RepairableUnits, ScanAndCover, hero Vanguard.SovereignBattlecruiser), Swarm (a biomass faction with CreepSpread, LarvaMorph, FastEconomy, hero Swarm.BroodMother), and Choir (a psionic faction with WarpInProduction, ShieldedUnits, ArchonMerge, hero Choir.ArchTemplar). BuildUnits() defines 24 launch units, exactly eight per faction, each carrying flags that drive real mechanics — bWorker, bCloaked, bDetector, bShielded, bRepairable — and an Ore/Crystal cost (a Choir ArchTemplar costs 500/500, a Swarm Zergling just 25/0). On top sit nine tech tiers (three per faction) and three build-order wiki docs with timed steps — the Vanguard one-base-pressure opener hits TrainWorker at 12 s, BuildBarracks at 42 s, a marine pair at 78 s, all at the genre's worker counts.

The competitive backbone is UV4AsymmetricRTSLadder. BuildRanks() returns the seven-rank ladder (Bronze → Silver → Gold → Platinum → Diamond → Master → Grandmaster), and the matchmaking math is a real Glicko-2 implementation, not a stub: ApplyGlicko2Result (:350) computes the q = ln(10)/400 constant, the g(RD) deviation-scaling factor, the expected score E, the estimated variance , and the updated rating and deviation — then ApplySeasonalReset pulls a player's rating halfway back to the 1500 baseline each season. AsymmetricRTSSpec proves a win raises the rating, a placement match decrements the five-match counter, and a reset restores it. Replays are input-only: UV4AsymmetricRTSReplayLibrary::BuildInputStreamReplay sorts a command list by frame and mints a public https://replays.v4.game/rts/asymmetric/… URL — the "download any ladder match" promise as concrete code.

Age-of-Empires-style — twelve civilizations, five ages, Wonders#

V4Mode_RTS_Historical is the slower, population-heavy historical game. UV4HistoricalRTSCatalog::BuildCivilizations() authors twelve launch civilizations, each with unique units and a Wonder: Britons (Longbowmen, Stonehenge), Mongols (Mangudai), Aztecs (Jaguar Warrior), Japanese (Samurai), Vikings (Berserker), Mali (Camel Archer + Gbeto), Khmer (Ballista Elephant), Ethiopians (Shotel Warrior), Yorùbá (Ile-Ife Cavalry + Ogboni), Bantu/Kongo (Nkisi Mage + Kanga Spearman), Persians (War Elephant), and Aboriginal Australians (Boomerang Skirmisher, Uluru). Crucially, every civ row carries sensitivity-review metadatabSensitivityReviewed, a CulturalReviewId, and a non-empty RepresentationNote — and HistoricalRTSSpec fails the build if any civ is missing it, encoding the §61 cultural-review gate as a test rather than a promise.

The historical game runs a four-resource economy (Food/Wood/Gold/Stone, distinct from the StarCraft cell's two-resource Ore/Crystal) and a five-age ladder (Dark → Feudal → Castle → Imperial → Industrial). UV4HistoricalAgeModel scales age-up cost with the number of bases, and UV4HistoricalWonderVictoryComponent models the Wonder win exactly: you must finish construction before the defense timer can start, then survive the seven-minute (419 + 1 s in the spec) defense to win. The headline scale claim is measured, not asserted: UV4HistoricalPopulationModel::CaptureFourPlayerFFAProfile simulates a four-player free-for-all at a 200-population cap — 4 × 200 = 800 units — over 250 frames and records real Average/P95/Max frame times, and the feel gate passes only if P95 ≤ 16.7 ms and Max ≤ 22 ms. That is the "200 villagers without stutter" requirement turned into a timed automation profile.

Base, unit, and economy systems#

The pieces below live in the shared V4RTS core, so both macro games inherit the same simulation behavior. Each is a UActorComponent with bCanEverTick = false — the sim is advanced explicitly by the match loop, never by the engine tick, which is what keeps it lockstep-safe.

The Ore/Crystal economy#

UV4ResourceComponent (V4/ue/Source/V4RTS/Private/V4ResourceComponent.cpp) holds integer Ore and Crystal and exposes AddResources, SpendResources, and CanAfford over an FV4RTSResourceCost. Gathering is the load-bearing formula: GatherFromPatch returns min(Patch.Remaining, GatherPerSecond · DeltaSeconds · WorkerCount), floors the result to whole resources, banks them into the right resource (Ore or Crystal by the patch's bCrystal flag), and depletes the patch. With the default GatherPerSecond = 8, EconomySpec pins the arithmetic: three workers on a 100-unit patch for two seconds gather exactly 48 (8 × 2 × 3), bank 48 Ore, leave the patch at 52, and a 30-Ore spend leaves 18. That is a genuine economic rate model — the thing a build order is timed against — not a counter that ticks up by a constant.

Base building and the production queue#

UV4BuildComponent makes placement spatial. CanPlaceStructure walks every cell of an FV4RTSBuildOrder's footprint against the FV4RTSGrid, rejecting any cell that is out of bounds or blocked, and QueueStructure atomically validates placement and spends the cost before enqueuing — if the resource spend fails, nothing is placed. Units come from UV4TrainComponent: QueueUnit charges the cost up front, and TickProduction(DeltaSeconds) drains real time against the head order's TrainSeconds (default 5), completing it and broadcasting OnUnitTrained with the finished unit's gameplay tag, then rolling to the next queued order with any leftover delta. Both are FIFO queues with honest partial progress — the macro player's "queue five workers and tab away" loop.

Tech trees and age-up#

UV4TechTreeComponent is a real dependency graph, not a flat unlock list. CanResearchTech checks four conditions in order: the tech exists, it is not already unlocked, the requesting faction tag matches (so a Vanguard player cannot research a Choir tech), every prerequisite tag is already unlocked, and the cost is affordable; only then does ResearchTech spend and unlock. Age progression is UV4AgeUpComponent over EV4RTSAge { Dark, Feudal, Castle, Imperial }: TryAgeUp starts from a base cost (500 Ore, 200 Crystal), scales it by population pressure (+4 Ore and +2 Crystal for every point of population above 50), refuses to advance past Imperial, and broadcasts OnAgeChanged. The cost-scaling is the design rule — "age-up cost scales with population pressure" — implemented as arithmetic on the live population count.

Fog of war and unit control#

UV4FogOfWarComponent implements the canonical three-state fog over a flat cell array: EV4RTSFogState { Unexplored, Explored, Visible }. RevealCircle uses a true squared-distance radius test (Δx² + Δy² ≤ r²), and BeginVisibilityFrame demotes every Visible cell back to Explored at the top of each frame so vision is recomputed from current unit positions — the standard "grayed memory vs. live sight" model. FogSpec checks a radius-one reveal lights exactly the five-cell plus-shape, the diagonal stays dark, and a frame reset returns every cell to Explored. Selection is UV4UnitGroupComponent: AssignGroup(Hotkey, Units) stores a control group keyed by number, filtering out invalid actors on both write and read, with GetGroup and ClearGroup — the Ctrl+1 muscle memory of the genre. The asymmetric plugin extends fog with cloak/detector logic: CanSeeCloakedUnit returns true only when a cell is Visible and a detector is in range.

Wonders and the co-op tower-defense variant#

UV4WonderComponent is the win-condition timer: StartWonderTimer arms it, TickWonder accumulates defended time against RequiredDefenseSeconds = 420.0f (seven minutes), and crossing it latches bVictoryReady and fires OnWonderVictoryReady. A third plugin, V4Mode_RTS_TowerDefense, reuses the same map and Wonder substrate for a co-op PvE variant: V4TowerDefenseSystems authors FV4TDPlayerSlots where one Defender controls the Wonder and up to three Attackers control the incoming waves, plus FV4TDWaveDefinition rows carrying enemy budget, spawn-lane count, target pace, and boss-wave flags across lane-typed maps — the "one player defends, one-to-three attack" asymmetric tower-defense the catalogue describes.

The deterministic sim#

The RTS cell is the one place in V4 where the simulation must be bit-identical across machines, because lockstep replicates only inputs. V4RTS takes that seriously at the type level.

flowchart TB Input["player commands<br/><sub>build · train · research · attack-move</sub>"] --> Abil subgraph Abil["V4RTSAbilities · GAS (cell-gated to RTS)"] Build[UGA_BuildStructure] Train[UGA_TrainUnit] Tech[UGA_ResearchTech] Move[UGA_AttackMove] end subgraph Sim["V4RTS — deterministic simulation"] Fixed["FV4RTSFixed32<br/><sub>32.32 fixed-point</sub>"] Path["UV4PathfindingSubsystem<br/><sub>seeded A* · self-audited</sub>"] Econ["UV4ResourceComponent<br/><sub>Ore/Crystal · 8/s gather</sub>"] BuildC["UV4BuildComponent / UV4TrainComponent<br/><sub>10s build · 5s train · FIFO</sub>"] TechC["UV4TechTreeComponent / UV4AgeUpComponent<br/><sub>prereqs · Dark→Imperial</sub>"] Fog["UV4FogOfWarComponent<br/><sub>Unexplored/Explored/Visible</sub>"] end Build --> BuildC Train --> BuildC Tech --> TechC Move --> Path Econ --> BuildC Fixed --> Path Sim --> Lock["V4Netcode · UV4RTSLockstepSubsystem<br/><sub>25 Hz · chained hash + SplitMix64 · desync recovery</sub>"]

Fixed-point and seeded pathfinding#

FV4RTSFixed32 (V4/ue/Source/V4RTS/Public/V4RTSTypes.h:24) is a 32.32 fixed-point number — FractionalBits = 32, OneRaw = 1ll << 32 — with integer add, subtract, and ordering operators and a ToIntegerFloor. It exists so the simulation never touches a float where two machines might round differently. UV4PathfindingSubsystem::FindPath is a real A* over an FV4RTSGrid that uses fixed-point G/F scores and a Manhattan-distance Heuristic, and its tiebreak is the determinism-critical detail: when two open-set nodes have equal F, it orders them by V4CellSortKey = HashCombine(cellHash, Seed), so the expansion order is reproducible from the match seed rather than pointer- or insertion-dependent. The neighbor list is seed-sorted for the same reason. AuditDeterministicPathfinding is a built-in self-test that runs the same query twice under seed 314, asserts the paths match step-for-step, and checks that the (0,0) → (3,4) heuristic equals exactly 7 cells — determinism verified in code, not asserted in prose. BuildFlowFieldNextSteps derives a per-cell flow field toward a goal for cheap group movement.

Lockstep, hashing, and desync recovery#

V4RTS.Build.cs depends on V4Netcode, where the lockstep transport lives. UV4RTSLockstepSubsystem (a UGameInstanceSubsystem) advances on a fixed integer step — 25 Hz, 40 ms — so the same input stream produces the same frame count regardless of render rate. Each frame it gathers pending inputs, runs them through a single canonical sort (by player id, then command, then values, then target cell) so two clients that received inputs in different network order hash them identically, then chains a determinism digest via ComputeDeterministicHash. The load-bearing subtlety is FName hashing: a raw GetTypeHash(FName) hashes the process-local name-table index (different per machine), so the subsystem uses StableNameHash = FCrc::StrCrc32 over the name string, making the digest content-based and machine-stable. Match RNG is a named V4SplitMix64 seeded per match, and every draw is logged and order-audited so non-deterministic iteration cannot silently consume the stream differently on two peers. When a peer hash disagrees, RecoverFromDesync rolls back Frame − RecoveryRollbackFrames (default 4) and applies the authoritative snapshot; the whole input stream serializes to the input-only replay (~1 MB per 30-minute match). The full, honestly-qualified treatment — including why this is a deterministic library rather than a live net driver — is in ../architecture/networking-determinism.md.

What's real, and what's thin#

Read adversarially, the maturity story is uneven but honest. Real and tested: the fixed-point type, the self-auditing seeded A*, the gather/spend economy, grid-footprint building, the timed production queue, the prerequisite-and-faction-gated tech tree, population-scaled age-up, three-state fog with cloak/detector, control groups, the Wonder timer, the Glicko-2 ladder, the input-only replay, and the measured 800-unit FFA frame profile — each pinned by a spec that checks a computed value. Thin or not-yet-wired: there is no single in-tree match actor that ties these components into one running loop (the specs drive them in isolation); unit combat resolution is the least-developed systemUGA_AttackMove only assigns a control group and trained units are gameplay tags, so there is no RTS-specific damage/targeting/death sim yet; the lockstep digest hashes inputs and seed but not the sim's own unit state; and every map, faction, and civ is a validated data manifest, not authored, cooked content. The bones of the genre are real and correct; the flesh of a shippable match — fought battles, a composed match loop, baked terrain — remains.

How the RTS cell ties to the shared engine#

Like every V4 cell, the RTS surface routes player intent through the Gameplay Ability System, and its abilities are cell-gated so an RTS ability cannot fire on a non-RTS pawn. The four abilities in V4RTSAbilities.cppUGA_BuildStructure (a default 16×16 grid, 2×2 footprint), UGA_TrainUnit, UGA_ResearchTech, and UGA_AttackMove — each set RequiredCell = EV4RulesetCell::RTS, delegate to the components rather than reimplementing them, and fail loud with a precise reason string ("build-unavailable", "train-unavailable", "research-unavailable", "missing-tech-tree") instead of faking success. That is the same GAS spine that carries an operator's attributes from a Wukong boss duel to an RTS hero unit, and the same fail-loud seam the whole project favors.

The shared roster is the connective tissue: a named operator who is a hero in the Action-RPG cell appears here as an RTS hero unit, earning the same cross-cell Operator XP. For the souls-like sibling on the same GAS backbone, see ./cell-action-rpg-wukong.md; for the GAS layout, the perception model, the netcode authority, and the mode machinery every cell sits on, see ./shared-cross-cell-engine.md. For the engine-side, genre-by-genre view of this module's determinism and maturity, read the architecture companion, ../architecture/per-cell-deep-dives.md.