Last modified: June 06, 2026
This article is written in: πΊπΈ
Victory rules look simple on the surface: destroy the enemy, hold out for a timer, protect your commander. The tricky part is making those rules fast enough to check every frame, flexible enough for missions and skirmishes, and explicit enough that content authors can tell what will actually happen.
This document walks through the current victory architecture: how map and mission content become runtime rules, how the engine evaluates those rules cheaply, why the commander is now the default defeat anchor, and what needs to change when we add new objective families later.
VictoryServiceThe runtime no longer interprets JSON-like strings on every update. Instead, content is translated into typed rule payloads once, then the service evaluates those payloads against a compact summary of the world.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β CONTENT LAYER β
β β
β assets/maps/*.json assets/missions/*.json β
β βββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββ β
β β VictoryConfig β β MissionDefinition β β
β β - type β β - victory_conditions[] β β
β β - key_structures[] β β - defeat_conditions[] β β
β β - defeat_conditions[] β β β β
β ββββββββββββ¬βββββββββββββ ββββββββββββββββββββ¬ββββββββββββββββββ β
β β β β
βββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββ
β β
βΌ βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β TRANSLATION LAYER β
β β
β victory_service.cpp mission_victory_rules.cpp β
β - build_rule_set_from_config() - build_victory_rules() β
β - map/skirmish defaults - mission defaults β
β - string normalization - condition normalization β
βββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β RUNTIME LAYER β
β β
β VictoryRuleSet β
β βββ victory_rules[] # OR semantics β
β βββ defeat_rules[] # OR semantics β
β β
β VictoryService β
β βββ summarise_world() once when dirty β
β βββ evaluate victory rules β
β βββ evaluate defeat rules β
β βββ finalize_game(\"victory\" | \"defeat\") β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The important design choice is that translation and evaluation are separate concerns. Content-facing strings stay at the edge. The service itself works with typed rule payloads.
If content does not declare explicit defeat conditions, the engine now applies exactly these two defaults:
This is the baseline defeat model for both map-driven and mission-driven play.
no_key_structures is still supported, but it is now an explicit opt-in rule. The default defeat model is commander-centric, not structure-centric. That matters because many missions want barracks pressure without making every lost building an automatic failure state.
only_commander_remainingonly_commander_remaining is meant to express being reduced to only the commander, not starting with only the commander.
To enforce that, the rule only becomes armed after the local player has previously owned at least one of the following:
That prevents false defeats on commander-only openings, scripted reinforcement starts, and similar setups.
The runtime stores rules in a VictoryRuleSet:
struct VictoryRuleSet {
std::vector<VictoryRule> victory_rules;
std::vector<DefeatRule> defeat_rules;
};
VictoryRule and DefeatRule are typed variants. Each payload only stores the data that rule actually needs.
| Rule | Meaning | Payload |
EliminationVictoryRule |
Remove all tracked enemy structures | structure_types[] |
SurviveTimeVictoryRule |
Stay alive until timer expires | duration |
ControlStructuresVictoryRule |
Own enough tracked structures | StructureRequirement |
CaptureStructuresVictoryRule |
Capture enough foreign structures | StructureRequirement |
| Rule | Meaning | Payload |
NoUnitsDefeatRule |
Lose all local units | none |
NoKeyStructuresDefeatRule |
Lose all tracked structures | structure_types[] |
NoCommanderDefeatRule |
Commander is dead | none |
OnlyCommanderRemainingDefeatRule |
Commander is isolated and rule is armed | structure_types[] |
OnlyCommanderRemainingDefeatRule is parameterised over structure types even though current content uses barracks. That keeps the rule explicit instead of hiding a "barracks" literal deep in evaluation logic.
The expensive part of victory logic used to be repeated entity scans. The service now builds one WorldSummary and reuses it for every active rule:
struct WorldSummary {
bool local_has_units = false;
int local_commander_count = 0;
int local_non_commander_troop_count = 0;
QHash<QString, int> enemy_structure_counts;
QHash<QString, int> local_owned_structure_counts;
QHash<QString, int> local_captured_structure_counts;
};
That summary is built only when the world is dirty for world-based rules. The service tracks which structure types matter up front, so it does not need to count unrelated buildings.
The service evaluates non-timer victory rules first, then defeat rules. Time-based victory is checked earlier in the update loop using elapsed time. In other words, if both a world-based victory and defeat become true in the same reevaluation, victory currently wins because it is checked first.
World-based rules do not need a full scan every tick. The service marks itself dirty and reevaluates on the events that matter to current rules:
UnitSpawnedEventUnitDiedEventBarrackCapturedEventThat is enough for the current rule catalog because all current world-based rules depend on force counts, commander presence, and structure ownership.
Adding a new variant alternative is not the whole job. A new rule may also need:
WorldSummaryFor example:
That is why the current design is described as extension-friendly, not magically plug-in.
VictoryConfig in game/map/map_definition.h is the map-facing format. build_rule_set_from_config() in game/systems/victory_service.cpp translates it into runtime rules.
Current supported map victory types:
eliminationsurvive_timecontrol_structurescapture_structuresCurrent supported map defeat condition strings:
no_unitsno_key_structuresno_commanderonly_commander_remainingIf defeat_conditions is empty, the translator injects:
["no_commander", "only_commander_remaining"]
Mission definitions use structured Condition entries. Game::Mission::build_victory_rules() translates them into the same runtime rule set.
Current supported mission victory condition types:
destroy_all_enemiessurvive_durationcontrol_structurescapture_structuresCurrent supported mission defeat condition types:
lose_all_unitslose_structurelose_commanderonly_commander_remainingIf a mission omits defeat conditions entirely, the same commander-default pair is added automatically.
The translators still normalize some legacy structure names. Most notably:
village β barracksThat keeps older content and partially migrated missions/maps working while the asset vocabulary remains in transition.
When adding a new rule, treat it as a small vertical slice:
victory_rules, defeat_rules, or both.refresh_rule_metadata() if the rule needs tracked world data.summarize_world() or add adjacent runtime state if the rule needs new inputs.If the new rule needs per-entity state, region progress, or subsystem-owned data, prefer adding that explicitly rather than smuggling more meaning into generic string fields.
The current implementation is centered in these files:
game/systems/victory_service.hgame/systems/victory_service.cppgame/map/mission_victory_rules.hgame/map/mission_victory_rules.cppgame/map/map_definition.hdocs/MISSION_FRAMEWORK.mdtests/systems/victory_service_test.cpptests/map/mission_victory_rules_test.cpptests/map/mission_asset_rules_test.cppThe victory system is now built around one rule set, one world summary, and one default defeat philosophy: protect the commander, and do not let the commander become the only thing left. Everything else is explicit content.