Grand Strategy Engine - Moddable Mechanics Architecture
October 5, 2025 · View on GitHub
Executive Summary
Challenge: Support Paradox-style scripted mechanics (decisions, events, modifiers) while maintaining deterministic simulation and 200+ FPS
Solution: Compile scripts to bytecode, use fixed-size modifier arrays, aggressive caching
Performance: <0.1ms to evaluate 1000 conditions, <1ms to apply 100 effects
Key Innovation: Separate static data (scripts) from runtime state (modifiers)
Core Architecture Principles
The Three Layers
1. DATA LAYER (Paradox Scripts)
├── Effects (fervor_trade, etc)
├── Decisions
├── Events
└── Modifiers
2. COMPILED LAYER (Runtime Structures)
├── Effect Templates (compiled)
├── Condition Bytecode
├── Modifier Arrays
└── String→ID Maps
3. EXECUTION LAYER (Hot Path)
├── Active Effects Tracking
├── Modifier Accumulation
├── Condition Evaluation
└── Event Processing
Modifier System Architecture
Fixed Modifier Registry
// ALL possible modifiers defined at compile time
public enum ModifierType : ushort {
// Economic (0-99)
TaxEfficiency = 0,
ProductionEfficiency = 1,
TradeEfficiency = 2,
GlobalTradePower = 3,
GlobalTradeGoodsSize = 4,
LocalTradePower = 5,
// Military (100-199)
LandMorale = 100,
NavalMorale = 101,
Discipline = 102,
MoraleDamage = 103,
ShockDamage = 104,
FireDamage = 105,
// Diplomatic (200-299)
DiplomaticReputation = 200,
ImproveRelationModifier = 201,
AggressiveExpansion = 202,
SpyActionCost = 203,
// Development (300-399)
DevelopmentCost = 300,
BuildingCost = 301,
GlobalUnrest = 302,
LocalUnrest = 303,
// ... up to 65,535 modifier types
}
// String mapping for script loading
public static class ModifierRegistry {
private static Dictionary<string, ModifierType> stringToType = new() {
["tax_efficiency"] = ModifierType.TaxEfficiency,
["production_efficiency"] = ModifierType.ProductionEfficiency,
["trade_efficiency"] = ModifierType.TradeEfficiency,
["global_trade_power"] = ModifierType.GlobalTradePower,
// ... populated from config file
};
public static ModifierType GetType(string name) {
return stringToType.TryGetValue(name, out var type) ?
type : ModifierType.Invalid;
}
}
Nation & Province Modifiers
// Fixed-size, cache-friendly modifier storage
[StructLayout(LayoutKind.Sequential)]
public struct ModifierArray {
private const int MAX_MODIFIERS = 512;
private fixed float values[MAX_MODIFIERS];
public float this[ModifierType type] {
get => values[(int)type];
set => values[(int)type] = value;
}
public void Clear() {
// Vectorized clear for performance
fixed (float* ptr = values) {
Buffer.MemoryCopy(Zero, ptr, MAX_MODIFIERS * 4, MAX_MODIFIERS * 4);
}
}
public void Add(ModifierType type, float value) {
values[(int)type] += value;
}
}
public class NationState {
public byte nationId;
public ModifierArray modifiers; // Base modifiers
public ModifierArray tempModifiers; // Temporary (events, decisions)
public float GetModifier(ModifierType type) {
return modifiers[type] + tempModifiers[type];
}
}
Effect System
Effect Templates (Compiled from Scripts)
public class EffectTemplate {
public ushort id;
public string name; // For debugging/UI
public ModifierEffect[] modifiers; // What modifiers to apply
public ScriptedEffect[] scripted; // Custom coded effects
public ConditionBytecode potential; // When visible
public ConditionBytecode trigger; // When can activate
public int cost; // Resource cost
}
public struct ModifierEffect {
public ModifierType type;
public float value;
public bool isMultiplier; // true = multiply, false = add
}
// For complex effects that need code
public abstract class ScriptedEffect {
public abstract void Apply(GameState state, byte nation);
public abstract void Remove(GameState state, byte nation);
}
Active Effect Tracking
public class ActiveEffectSystem {
// Per-nation active effects
private struct ActiveEffect {
public ushort templateId;
public float startTime;
public float endTime; // float.MaxValue for permanent
public float strength; // For scaling
public byte source; // What triggered this
}
// Fixed pool to avoid allocation
private ActiveEffect[] effectPool;
private int poolSize = 10000;
private Stack<int> freeIndices;
// Per nation tracking
private List<int>[] nationEffects; // Indices into pool
private bool[] nationsDirty;
public void ActivateEffect(byte nation, ushort effectId, float duration = -1) {
if (freeIndices.Count == 0) return; // Pool exhausted
int index = freeIndices.Pop();
effectPool[index] = new ActiveEffect {
templateId = effectId,
startTime = TimeManager.GameTime,
endTime = duration > 0 ? TimeManager.GameTime + duration : float.MaxValue,
strength = 1.0f
};
nationEffects[nation].Add(index);
nationsDirty[nation] = true;
}
public void RecalculateNationModifiers(byte nation) {
if (!nationsDirty[nation]) return;
var modifiers = nationStates[nation].tempModifiers;
modifiers.Clear();
// Apply all active effects
foreach (int effectIndex in nationEffects[nation]) {
var effect = effectPool[effectIndex];
if (TimeManager.GameTime > effect.endTime) {
// Expired - return to pool
freeIndices.Push(effectIndex);
continue;
}
var template = effectTemplates[effect.templateId];
foreach (var mod in template.modifiers) {
modifiers.Add(mod.type, mod.value * effect.strength);
}
}
nationsDirty[nation] = false;
}
}
Condition System (High-Performance)
Bytecode VM Architecture
OpCode Categories: Stack ops (Push/Pop), Variable access (GetNationFlag, GetProvinceValue), Comparisons (Equal, Greater, Less), Logic (And, Or, Not), Special (HasCountryFlag, IsAtWar)
Evaluation Engine: Stack-based VM uses stackalloc int[16] for zero-allocation execution. Iterates bytecode array, executing operations and maintaining evaluation stack. Final stack[0] contains boolean result.
// Simplified example showing architecture
public bool Evaluate(GameState state, byte nation) {
var stack = stackalloc int[16];
int stackPtr = 0;
for (int pc = 0; pc < code.Length; pc++) {
switch ((ConditionOpCode)code[pc]) {
case ConditionOpCode.PushInt:
stack[stackPtr++] = (int)constants[code[++pc]];
break;
case ConditionOpCode.Greater:
stackPtr--;
stack[stackPtr-1] = stack[stackPtr-1] > stack[stackPtr] ? 1 : 0;
break;
// ... other opcodes
}
}
return stack[0] != 0;
}
Compiler: Parses condition nodes and emits bytecode. Strings interned to IDs. See Unity Paradox Parser Guide for parsing phase. Full implementation in actual codebase.
Decision System
Decision Architecture
public class Decision {
public ushort id;
public string name;
public string description;
// Conditions
public ConditionBytecode potential; // Is visible?
public ConditionBytecode allow; // Can be taken?
// Effects
public EffectTemplate[] effects; // What happens
// Metadata
public bool majorDecision; // Special UI
public int cooldown; // Days before can retake
public DecisionCategory category;
// AI
public AIDecisionWeight aiWeight;
}
public class DecisionSystem {
private Decision[] allDecisions;
private ushort[][] availableDecisions; // Per nation, sparse
private float[] decisionCooldowns; // When can retake
// Update available decisions (bucketed)
public void OnWeeklyUpdate(int week) {
int nationsPerWeek = nationCount / 4; // Check each nation monthly
int startNation = (week % 4) * nationsPerWeek;
for (int n = startNation; n < startNation + nationsPerWeek; n++) {
UpdateNationDecisions((byte)n);
}
}
private void UpdateNationDecisions(byte nation) {
var available = new List<ushort>();
foreach (var decision in allDecisions) {
// Quick early-out checks first
if (decisionCooldowns[decision.id] > TimeManager.GameTime) continue;
// Check potential
if (decision.potential.Evaluate(gameState, nation)) {
available.Add(decision.id);
}
}
availableDecisions[nation] = available.ToArray();
}
public void TakeDecision(byte nation, ushort decisionId) {
var decision = allDecisions[decisionId];
// Verify allow conditions
if (!decision.allow.Evaluate(gameState, nation)) return;
// Apply effects
foreach (var effect in decision.effects) {
EffectSystem.ApplyEffect(nation, effect);
}
// Set cooldown
if (decision.cooldown > 0) {
decisionCooldowns[decisionId] = TimeManager.GameTime + decision.cooldown;
}
// Fire event
EventBus.Emit(new DecisionTakenEvent(nation, decisionId));
}
}
Event System
Event Architecture
public class GameEvent {
public ushort id;
public string title;
public string description;
public EventOption[] options;
// Triggering
public EventTrigger trigger;
public ConditionBytecode condition;
public float meanTimeToHappen; // In days
// Targeting
public EventScope scope; // Nation, Province, Global
public bool immediate; // Fire immediately when conditions met
public bool major; // Pause game
}
public class EventOption {
public string name;
public ConditionBytecode condition; // Is this option available?
public EffectTemplate[] effects;
public AIOptionWeight aiWeight;
}
public class EventSystem {
private GameEvent[] allEvents;
private Queue<FiredEvent> eventQueue;
private float[] eventMTTH; // Mean time to happen tracking
private struct FiredEvent {
public ushort eventId;
public byte nation;
public ushort province;
public float fireTime;
}
// Check events (heavily bucketed)
public void OnDailyUpdate(int day) {
// Only check 1/30th of events per day
int eventsToCheck = allEvents.Length / 30;
int startEvent = (day % 30) * eventsToCheck;
for (int i = startEvent; i < startEvent + eventsToCheck; i++) {
CheckEvent(allEvents[i]);
}
// Process fired events
ProcessEventQueue();
}
private void CheckEvent(GameEvent evt) {
switch (evt.scope) {
case EventScope.Nation:
foreach (byte nation in GetRelevantNations(evt)) {
if (evt.condition.Evaluate(gameState, nation)) {
if (evt.immediate) {
FireEvent(evt, nation);
} else {
UpdateMTTH(evt, nation);
}
}
}
break;
}
}
}
Custom Mechanic Framework
Base Mechanic Class
public abstract class GameMechanic {
public abstract string Id { get; }
public abstract UpdateFrequency UpdateRate { get; }
// Lifecycle
public abstract void Initialize(GameState state);
public abstract void Update(GameState state, float deltaTime);
public abstract void Cleanup();
// Events
public virtual void OnEvent(IGameEvent evt) { }
// Save/Load
public abstract void Serialize(BinaryWriter writer);
public abstract void Deserialize(BinaryReader reader);
// Hot reload support
public virtual void OnScriptsReloaded() { }
}
// Mechanic Registry
public class MechanicRegistry {
private Dictionary<string, GameMechanic> mechanics = new();
private List<GameMechanic>[] updateLists; // Per frequency
public void RegisterMechanic(GameMechanic mechanic) {
mechanics[mechanic.Id] = mechanic;
updateLists[(int)mechanic.UpdateRate].Add(mechanic);
mechanic.Initialize(gameState);
}
public void UpdateMechanics(UpdateFrequency frequency, float deltaTime) {
foreach (var mechanic in updateLists[(int)frequency]) {
mechanic.Update(gameState, deltaTime);
}
}
}
Example: Fervor Mechanic
public class FervorMechanic : GameMechanic {
public override string Id => "fervor";
public override UpdateFrequency UpdateRate => UpdateFrequency.Monthly;
// State
private float[] nationFervor;
private ushort[] activeFervorEffect;
private Dictionary<string, ushort> fervorEffects;
public override void Initialize(GameState state) {
nationFervor = new float[state.MaxNations];
activeFervorEffect = new ushort[state.MaxNations];
// Load fervor effects from scripts
LoadFervorEffects();
}
public override void Update(GameState state, float deltaTime) {
for (byte n = 0; n < state.NationCount; n++) {
if (!IsReformed(state, n)) continue;
// Generate fervor
nationFervor[n] += GetFervorGeneration(state, n) * deltaTime;
nationFervor[n] = Math.Min(nationFervor[n], 100f);
// Check if effect expired
if (nationFervor[n] < 8f && activeFervorEffect[n] != 0) {
DeactivateEffect(n);
}
}
}
public void ActivateFervorEffect(byte nation, string effectName) {
if (nationFervor[nation] < 8f) return;
var effectId = fervorEffects[effectName];
nationFervor[nation] -= 8f;
activeFervorEffect[nation] = effectId;
// Apply the effect through the effect system
EffectSystem.ActivateEffect(nation, effectId, -1); // Permanent until deactivated
}
private void DeactivateEffect(byte nation) {
if (activeFervorEffect[nation] == 0) return;
EffectSystem.DeactivateEffect(nation, activeFervorEffect[nation]);
activeFervorEffect[nation] = 0;
}
public override void OnEvent(IGameEvent evt) {
if (evt is ReligionConvertedEvent conv) {
if (conv.newReligion == Religion.Reformed) {
nationFervor[conv.nation] = 10f; // Starting fervor
}
}
}
}
Script Loading & Compilation
Parser to Runtime Pipeline
Integration with Unity Paradox Parser: This system uses the high-performance Burst-compiled parser for the initial parsing phase.
public class ScriptLoader {
private ParadoxParser parser;
private ConditionCompiler conditionCompiler;
private EffectCompiler effectCompiler;
public void LoadAllScripts(string basePath) {
var sw = Stopwatch.StartNew();
// Phase 1: Parse all files in parallel
var parseJobs = new ParallelBag<ParsedFile>();
Parallel.ForEach(Directory.GetFiles(basePath, "*.txt", SearchOption.AllDirectories), file => {
var content = File.ReadAllText(file);
var parsed = parser.Parse(content);
parseJobs.Add(new ParsedFile { Path = file, Root = parsed });
});
// Phase 2: Compile to runtime structures
foreach (var file in parseJobs) {
CompileFile(file);
}
// Phase 3: Link references
LinkAllReferences();
// Phase 4: Validate
ValidateAllScripts();
ArchonLogger.Log($"Loaded {parseJobs.Count} files in {sw.ElapsedMilliseconds}ms");
}
private void CompileFile(ParsedFile file) {
foreach (var node in file.Root.nodes) {
switch (DetectNodeType(node)) {
case ScriptType.Effect:
CompileEffect(node);
break;
case ScriptType.Decision:
CompileDecision(node);
break;
case ScriptType.Event:
CompileEvent(node);
break;
}
}
}
}
Hot Reload for Development
#if UNITY_EDITOR
public class ScriptHotReload : MonoBehaviour {
private FileSystemWatcher[] watchers;
private Queue<string> pendingReloads = new();
void Start() {
var paths = new[] {
"Assets/Scripts/Data/decisions",
"Assets/Scripts/Data/events",
"Assets/Scripts/Data/modifiers"
};
watchers = paths.Select(path => {
var watcher = new FileSystemWatcher(path, "*.txt");
watcher.Changed += OnFileChanged;
watcher.Created += OnFileChanged;
watcher.EnableRaisingEvents = true;
return watcher;
}).ToArray();
}
void OnFileChanged(object sender, FileSystemEventArgs e) {
lock (pendingReloads) {
pendingReloads.Enqueue(e.FullPath);
}
}
void Update() {
lock (pendingReloads) {
while (pendingReloads.Count > 0) {
var file = pendingReloads.Dequeue();
ReloadScript(file);
}
}
}
void ReloadScript(string path) {
try {
var content = File.ReadAllText(path);
var parsed = ParadoxParser.Parse(content);
var compiled = ScriptCompiler.Compile(parsed);
// Hot-swap in place
ScriptRegistry.Replace(compiled);
// Notify mechanics
MechanicRegistry.NotifyScriptsReloaded();
ArchonLogger.Log($"Hot-reloaded: {Path.GetFileName(path)}");
}
catch (Exception e) {
ArchonLogger.LogError($"Failed to reload {path}: {e.Message}");
}
}
}
#endif
Performance Optimizations
String Interning Optimization
Flags stored as ushort IDs instead of strings. FlagRegistry maintains bidirectional mapping (string ↔ ID). Nations store BitArray (65536 bits = 8KB) for flag storage. Flag checks become bit lookups instead of string comparisons (10x+ faster).
Modifier Caching
public class ModifierCache {
private struct CachedValue {
public float value;
public uint version;
}
private CachedValue[,] cache; // [nation, modifierType]
private uint[] nationVersions;
public float GetModifier(byte nation, ModifierType type) {
ref var cached = ref cache[nation, (int)type];
if (cached.version != nationVersions[nation]) {
// Recalculate
cached.value = CalculateModifier(nation, type);
cached.version = nationVersions[nation];
}
return cached.value;
}
public void InvalidateNation(byte nation) {
nationVersions[nation]++;
}
}
Memory Layout
Memory Footprint (256 nations, 10k provinces)
``$ \text{Core} \text{Systems}:
- \text{Nation} \text{modifiers}: 256 \times 512 \times 4 \text{bytes} = 512\text{KB}
- \text{Province} \text{modifiers}: 10\text{k} \times 128 \times 4 \text{bytes} = 5\text{MB}
- \text{Active} \text{effects} \text{pool}: 10\text{k} \times 32 \text{bytes} = 320\text{KB}
- \text{Flag} \text{registry}: 64\text{k} \times 8 \text{bytes} = 512\text{KB}
- \text{Total}: ~7\text{MB}
\text{Script} \text{Data}:
- \text{Effect} \text{templates}: ~2000 \times 200 \text{bytes} = 400\text{KB}
- \text{Decisions}: ~500 \times 300 \text{bytes} = 150\text{KB}
- \text{Events}: ~1000 \times 500 \text{bytes} = 500\text{KB}
- \text{Bytecode}: ~2\text{MB}
- \text{Total}: ~3\text{MB}
\text{Runtime} \text{State}:
- \text{Effect} \text{tracking}: 256 \times 100 \times 8 \text{bytes} = 200\text{KB}
- \text{Decision} \text{cooldowns}: 500 \times 4 \text{bytes} = 2\text{KB}
- \text{Event} \text{MTTH}: 1000 \times 4 \text{bytes} = 4\text{KB}
- \text{Total}: ~206\text{KB}
\text{GRAND} \text{TOTAL}: ~10\text{MB} \text{for} \text{entire} \text{mechanic} \text{system} $``
Performance Guarantees
Operation Timings
Condition evaluation: <0.0001ms per condition
Effect application: <0.001ms per effect
Decision check (nation): <0.1ms for all decisions
Event check: <0.01ms per event
Modifier recalculation: <0.1ms per nation
Full update cycle (10k provinces, 256 nations):
- Check 100 events: 1ms
- Update 50 decisions: 5ms
- Apply 200 effects: 0.2ms
- Recalc dirty modifiers: 2ms
- Total: <10ms per second (maintains 100+ FPS)
Best Practices
- Compile everything - Never interpret scripts in hot path
- Use bytecode for conditions - 10x faster than expression trees
- Fixed-size arrays for modifiers - Avoid dictionary lookups
- Intern all strings - Compare ushorts, not strings
- Bucket expensive checks - Spread across multiple frames
- Cache aggressively - Recalc only when dirty
- Pool objects - Zero allocations in hot path
- Separate data from logic - Scripts define data, code executes
Related Documents
- Data Linking Architecture - Reference resolution system that mods use for linking string IDs to runtime entities
- Unity Burst Jobs Architecture - Burst-compiled Paradox parser used for high-performance script loading
Summary
This architecture enables:
- 750+ script files loaded and compiled in <1 second
- 1000+ conditions evaluated per frame at <0.1ms
- Hot reload for development without restart
- Zero allocations during gameplay
- Deterministic for multiplayer/saves
- Moddable while maintaining performance
The key is separation: scripts define WHAT (data), compiled bytecode defines WHEN (conditions), and native code defines HOW (execution). This gives you Paradox-style moddability with 10-100x better performance.