Skip to main content

Deferred Ideas

Features that have been discussed, analyzed, and designed but are deferred to a later phase. Each entry includes the analysis, why it's deferred, and what would change if implemented.


Auto-Modifiers (Condition-Driven Apply/Remove)

Status: Deferred — existing while pattern covers 90% of the use case.

What it is

Auto-modifiers are declarative rules: "whenever this condition is true, apply this modifier." Unlike while (trigger-gated bindings), auto-modifiers truly create and destroy bindings based on condition changes — no binding exists when the condition is false.

EU5 equivalent: common/auto_modifiers/

.secs syntax (proposed)

auto_modifier Overcrowded
{
scope = settlement;
condition @settlement.resolve(Population) > @settlement.resolve(PopulationCap);
}

auto_modifier FertileLand
{
scope = district;
condition @district.resolve(Fertility) > 2.0;
}

How it differs from while

Aspectwhile (trigger-gated)Auto-modifier (create/destroy)
Binding when falseExists (state = Inactive)Does not exist
Memory when inactive~64 bytes per bindingZero
Tooltip when inactiveCould show "inactive"Nothing shown
Trigger evaluationBindingUpdater passSeparate pipeline step
State change costToggle a byteFull AddModifier/RemoveModifier
Where declaredInside template/event codeSeparate declaration file
ScopePer-entity (added by specific code)Global (all entities of scope type)

Why it's deferred

  1. while already works — trigger-gated bindings achieve the same functional result. Stats only count Active bindings.
  2. Tooltip filtering — inactive bindings are already filtered from stat tooltips in the resolution detailed path (binding.State != ModifierState.Active check).
  3. Memory overhead is minimal — ~64 bytes per inactive binding. At 10K entities with 3 inactive bindings each, that's ~1.9 MB.
  4. Performance overlap — auto-modifiers would add a separate per-entity condition evaluation pass that duplicates what BindingUpdater already does for trigger-gated bindings.

What would change if implemented

  • New AutoModifierDeclaration struct (modifierId, scopeId, condition delegate)
  • New AutoModifierStep in the tick pipeline (evaluates conditions, calls AddModifier/RemoveModifier)
  • Tracking set: which entities currently have the auto-modifier applied
  • Registration in SecsRegistry
  • .secs content files in Content/auto_modifiers/
  • Documentation in a new doc

Workaround (current)

Use a system with while:

system AutoModifiers
{
phase Production;
frequency Daily;

void Execute()
{
foreach settlement in Settlement
{
settlement.add_modifier Overcrowded
while settlement.resolve(Population) > settlement.resolve(PopulationCap);
}
}
}

This is functionally equivalent. The binding is created once and toggled Active/Inactive by the trigger system.


Stat-Based Decay

Status: Deferred — linear tick-based decay is implemented. Stat-based decay is a future extension.

What it is

Instead of decaying over time (tick-based), the decay factor is driven by a stat value. Example: a siege modifier that weakens as the defender's garrison increases.

How it would work

modifier SiegeAttrition
{
decay = stat;
decay_stat = Garrison;
decay_range = 0..50; // at garrison 0: full effect. At garrison 50: zero effect.
Morale += -30;
Defense *= 50%;
}

The decay factor would be 1.0 - clamp(statValue / maxRange, 0, 1).

Why it's deferred

Tick-based linear decay (DecayMode.Linear) covers the common case. Stat-based decay requires a formula delegate in the decay path, which adds complexity to the resolution hot path. Can be added later as a new DecayMode enum value without breaking changes.


Manual Decay (Per-Hit Absorption)

Status: Deferred — specialized pattern.

What it is

A modifier that decays only when explicitly decremented. Example: a shield that absorbs damage — each hit reduces the shield's strength until it breaks.

modifier MagicShield
{
decay = manual;
total_charges = 5;
Defense += 20;
}

// In combat code:
settlement.decay_modifier MagicShield amount 1; // remove one charge

Why it's deferred

This is a niche pattern that can be modeled with existing tools: use an Accumulative stat as a "charge counter" and a while trigger that deactivates the modifier when charges reach 0.


Exponential / Step Decay Curves

Status: Deferred — linear is the 99% case.

What it is

Additional DecayMode values:

  • Exponential(remaining / total)² — holds strength longer, drops fast at end
  • InverseExponentialsqrt(remaining / total) — drops fast initially, holds at end
  • Step(threshold) — full strength until threshold %, then drops

Why it's deferred

The DecayMode enum is extensible. Adding new values is a non-breaking change — just a new case in the resolution switch statement. Linear covers the overwhelming majority of use cases.


Hook Chaining Priority

Status: Deferred — current chaining is sequential.

What it is

When an on-action chains to sub-on-actions, the chain currently fires in array order. A priority system would allow mods to control chaining order.

Why it's deferred

Only 0 chained on-actions exist in the example game currently. When a real game needs this, the ChainedOnActionIds array can be replaced with a sorted list of (priority, onActionId) pairs.


Activity Target Selection UI Integration

Status: Deferred — ActivityExecutor exists but isn't exposed in the web API.

What it is

The ActivityExecutor can evaluate activities, check costs, and execute effects. But GameHub.cs doesn't expose endpoints for:

  • Listing available activities for the player
  • Getting valid targets for targeted activities
  • Executing activities from the UI

Why it's deferred

The activity execution system is fully functional on the engine side. The server API integration is game-specific UI work, not engine work. Each studio would wire it differently based on their UI framework.

What would change

  • New GameHub methods: GetAvailableActivities(), ExecuteActivity(activityId), GetValidTargets(activityId)
  • New snapshot types for activity UI display
  • Client-side activity panel components

Runtime-Authored / Node-Composed Spellcrafting

Status: Deferred proposal-only — not a committed Valenar design.

What it is

Future Valenar spellcrafting may eventually support runtime-authored spells, persisted custom spell definitions, and/or node-composed spell graphs. That future shape may need a stable spell ref/id concept, but names such as SpellRef, SpellId, and CustomSpellDefinition are examples only, not committed APIs.

What is committed today

The current committed shape remains SpellDefinition data plus one generic Activity_CastSpell.

Why it's deferred

No committed spell-authoring model exists yet. Until one does, runtime/lowering helper seams should not be promoted into API, save, or syntax commitments.

What is explicitly uncommitted

Exact type names, APIs, graph schema, save shape, and wire formats are all uncommitted until a future spell-authoring model is actually designed and accepted.


Parallel Entity Resolution

Status: Deferred — ChannelCache with transitive dirty invalidation already eliminates the primary bottleneck. Parallelism adds significant complexity for marginal gain at current scale.

What it is

Partition AllByContract entity spans across threads for systems that only read stats. EU5/Vic3 uses a "parallel pre-update (read-only) → serial apply (write)" pattern. Unity DOTS does this with [ReadOnly]/[ReadWrite] component access declarations and EntityCommandBuffer for deferred writes.

How the industry does it

  • Paradox (Vic3): Modifier nodes sorted into dependency layers. Nodes within a layer have no inter-dependencies → evaluate in parallel. Layers execute sequentially. Was "one of the main contributors" to their 2022 performance improvement — but Vic3 has ~100K modifier nodes.
  • Unity DOTS: Systems declare in (read) vs ref (write) per component. Job scheduler auto-parallelizes read overlap. Structural changes deferred via EntityCommandBuffer, played back single-threaded.
  • UE5 Mass: Processors declare fragment access. Engine builds inter-processor dependency graph. Chunks distributed via ParallelForEachEntityChunk.

Why it's deferred

  1. Shared mutable state: ChannelResolver, ChannelCache, ModifierBindingStore, ChannelSourceStore, DirtySet, and Random are all non-concurrent. Making them thread-safe (via ConcurrentDictionary or per-thread cloning) adds GC pressure, contention overhead, or merge complexity.

  2. No system is truly read-only: Every Valenar system follows read → compute → CommandProcessor.Process() within the entity loop. Parallelism requires changing the semantic model from "commands apply immediately" to "commands deferred to end of step."

  3. Formulas are opaque delegates: Formula_FarmFood calls host.ResolveChannelFloat() which re-enters ChannelResolver — shared mutable state accessed from user-provided code. Thread safety of ISecsHostReads is a host contract that doesn't currently exist.

  4. ChannelCache already solves the bottleneck: Benchmarks show channel resolution dominates tick time (~65%). The cache with transitive dirty invalidation eliminates redundant resolution — the same optimization that gave Paradox their biggest win. Threading on top of caching gives diminishing returns.

  5. Scale mismatch: Paradox needed parallelism for ~100K modifier nodes. SECS targets 500-10K entities where single-threaded cached resolution completes in microseconds.

What would change if implemented

The path of least resistance (using existing ChannelCache DAG):

  1. [ReadOnly] attribute on ITickSystem — opts systems into the parallel pre-update phase
  2. Thread-local ChannelResolver pool — each worker thread gets its own resolver instance (avoids resolving HashSet contention), sharing a read-only ChannelCache snapshot
  3. Deferred CommandBuffer playback — parallel systems record commands into per-thread buffers with sort keys for deterministic ordering. Single-threaded playback after the parallel phase
  4. Thread-safe ISecsHostReads contract — new interface requirement: host reads must be safe from multiple threads. This is a breaking host contract change
  5. Dependency-layer batching — use the ChannelCache dependency DAG or the declared dependency graph (from ValidateDependencies) to partition entities into independent groups that can resolve in parallel

Prerequisites before this becomes worthwhile

  • Profiling evidence that single-threaded cached resolution is the bottleneck (not formula evaluation, not host reads)
  • A game with 50K+ entities where tick times exceed acceptable thresholds
  • Host implementations that guarantee thread-safe reads

Entity Query Indexing

Status: Deferred — current queries iterate linearly.

What it is

Pre-built indexes on channel values that allow queries like "all entities where Defense > 10" to skip entities without resolving. Could dramatically speed up any and first operations.

Why it's deferred

Benchmarks show channel resolution dominates query cost (~65%). With ChannelCache warm, resolution is ~10 ns per entity — fast enough that indexing adds write-side complexity without meaningful read-side gain at current scales. At 100K+ entities with many query operations per tick, indexing becomes worthwhile.

What would change

  • Channel-value index maintained on DirtySet changes
  • ChannelFilter struct predicate matching against the index
  • Modified InstanceStore.AllByContract accepting optional filters

Save/Load Serialization Hooks

Status: Superseded historical proposal. Current live contract is docs/design/10-host-secs-execution-boundary.md § 20.

What it is

This section originally proposed per-store Serialize(BinaryWriter) / Deserialize(BinaryReader) hooks on engine stores. That is no longer the committed shape. It remains here as provenance for the save/load discussion, but doc 10 § 20 is authoritative.

The current runtime contract is narrower:

  • SECS has partial store-level primitives today: ScopeSlotStore.Snapshot/Restore, ActivityRun.ToState, ActivityRunStore.Restore, and ActivityExecutor.SeedNextRunId.
  • Restore seeds the activity run id counter before restoring activity runs.
  • The host owns the save-file container, tick number, and RNG seed. SECS does not serialize System.Random state.
  • PrevTickSnapshotStore is warmed from live channel values after restore / first tick and is not durable payload.
  • A unified SecsSavePayload / SaveSnapshot / RestoreSnapshot API is a backlog candidate only, not a current runtime API.

What Paradox gets wrong

  1. Event state loss — Events vanish from queue on save/reload. persistent=yes events aren't tracked in history. CK3: saving mid-pilgrimage completes it without granting traits.
  2. Modifier accumulation bloat — No consolidation. Every +1 health for 10 years is a separate binding. CK3 characters accumulate dozens of tiny modifiers with no refresh/extend.
  3. Duration as expiry date — Paradox stores expiry_date="1480.1.1" instead of remaining ticks. Decay progress is lost entirely.
  4. Binary type IDs shift between patches — EU4 type 195 became 201 in v1.34, breaking save compatibility. No schema versioning.
  5. Save performance — Vic3 CPU spikes to 100% during autosave. EU4 saves reach 100MB/7M+ lines. No incremental saves.

Why SECS can do better

  • ReApplyMode (Refresh/Extend/Replace) prevents modifier accumulation bloat at the engine level.
  • Tick-based duration with TotalTicks/RemainingTicks serializes as two integers. Decay factor = remaining/total — fully reconstructable.
  • FNV-1a hash IDs are stable across builds (derived from names). Unlike Paradox's shifting ordinal type IDs.
  • Template-driven channel sources remain reconstructable only if a future restore design adds a no-lifecycle rehydrate path. The current TemplateActivator.Create / Activate route fires activation and lifecycle behavior, so it is not a valid durable restore path for already-created entities.

Historical persistent vs transient classification

This table is superseded by doc 10 § 20. It records the older proposal and the current correction side by side.

Store / surfaceHistorical proposalCurrent correction
InstanceStoreMust save entity graph.Restore remains a blocker; current activation/reactivation fires lifecycle behavior and is not a restore path.
ModifierBindingStoreMust save full binding state.Still a gap; no current snapshot/restore surface.
ChannelSourceStoreMaybe reconstruct from template activation.Must not use normal activation as restore for existing entities; future no-lifecycle rehydrate needs design.
Activity run stateNot fully represented in this older proposal.Partial primitive exists: ActivityRun.ToState + ActivityRunStore.Restore; call ActivityExecutor.SeedNextRunId first.
EventDispatcher.pendingChoicesMust save pending choices.Current queue is in-memory only; stable-key pending-choice persistence is future work.
TickContext.TickNumberMust save tick number.Host-owned save container field, not a SECS store serialization hook.
TickContext.RandomShould save RNG state / maybe custom RNG wrapper.Host owns seed and constructs TickContext.Random on restore; SECS does not serialize System.Random state.
PrevTickSnapshotStoreNot clearly separated in this older proposal.No durable payload; re-derived from live values after restore / first tick.
ChannelCache / DirtySet / scope indexesRebuild.Still runtime-derived; not durable save payload rows in the current contract.

Historical ChannelSource reconstruction idea

The older proposal assumed template activation could reconstruct channel sources:

  1. Restore host state (GameWorld data)
  2. Restore InstanceStore (entity graph with templateIds)
  3. Re-activate all templates → channel sources auto-registered in ChannelSourceStore
  4. Restore ModifierBindingStore (bindings with full state/ticks)
  5. Mark all channels dirty → resolve on first access

That restore order is not current contract. Normal activation is not idempotent restore: it can fire activation lifecycle behavior and create side effects. Doc 10 § 20 keeps InstanceStore restore as an explicit blocker until a no-lifecycle rehydrate path exists.

Engine vs host boundary

Historical proposal, not current API:

// Historical only: these APIs are not committed runtime surfaces.
instanceStore.Serialize(writer);
modifierBindingStore.Serialize(writer);
actionExecutor.SerializeCooldowns(writer);
eventDispatcher.SerializePendingChoices(writer);
writer.Write(ctx.TickNumber);

// Host-side serialization (game-specific)
gameWorld.Serialize(writer); // settlements, districts, buildings, resources

Current boundary: the host owns the serialized container and seed/tick fields. SECS contributes only the partial runtime payload objects that exist today, and a future unified payload is backlog design work.

Historical save format recommendation

  • Primary: Custom binary with section headers (archetype stream, binding stream, host stream). Each section has a length prefix for random access. This remains a host-format option, not a SECS runtime commitment.
  • Debug: JSON or text dump for human inspection.
  • Version header: Schema hash of registered stat/modifier/template IDs. On load, mismatch reports exactly what changed rather than silently corrupting.

Current backlog shape

  • Do not add per-store Serialize(BinaryWriter) / Deserialize(BinaryReader) hooks as an assumed default; first resolve the doc 10 § 20 unified-payload candidate.
  • If a unified payload lands later, it must start from today's partial primitives and hard-validate mismatches instead of fabricating defaults.
  • Restore order must call ActivityExecutor.SeedNextRunId before ActivityRunStore.Restore.
  • Keep host-owned seed/RNG ownership; no System.Random state serialization is committed.
  • Keep PrevTickSnapshotStore out of durable payloads.
  • Resolve the InstanceStore restore blocker with a no-lifecycle rehydrate path before treating template/channel-source reconstruction as durable save/load.

Template Composition / Inheritance

Status: Deferred — the flat/independent template pattern is the industry standard. Paradox ships 10+ titles without template inheritance. This is a compiler-phase feature if ever needed.

What it is

Allow templates to include other templates' contributions:

template<Building> FortifiedFarm : Farm, Fortification
{
// Inherits Farm's FoodOutput + Fortification's Defense
channel int Morale = 3; // own contribution
}

Compiles to a merged Activate() body that registers channel sources from all parents plus the child's own.

How the industry does it

Paradox scripting is overwhelmingly flat/independent:

  • EU5 buildings: Fully self-contained. Categories are empty tags with no inherited properties.
  • CK3 buildings: Flat definitions. next_building = castle_02 is a pointer, not inheritance — each tier redeclares everything.
  • Stellaris buildings: Same as CK3. upgrades = { building_X } is a pointer.
  • Vic3 building groups: The only real inheritance in Paradox scripting. parent_group = bg_manufacturing — child groups inherit defaults, can override. Single level only. Applies to groups (classification layer), not to buildings themselves.

Why it's deferred

  1. Flat works. SECS's current approach (independent templates, tier upgrades via TemplateActivator.Upgrade) matches how Paradox has shipped every game since EU3.

  2. This is a compiler feature, not an engine feature. The engine already handles composition naturally — ChannelSourceStore stacks multiple sources from the same owner, ModifierBindingStore accepts multiple bindings. A composed template's Activate() is just a merged method body. The engine sees a single flat TemplateEntry either way.

  3. Design decisions are unresolved:

    • Method conflicts: if both parents define OnBuilt(), chain them or error?
    • Field conflicts: if both parents define GoldCost, sum or override?
    • CanBuild() guards: AND all parents or child-only?
    • Diamond problem with mixins
  4. Compiler doesn't exist yet. Implementing composition in hand-written C# is just manual method delegation — no engine change needed.

What would change (compiler phase)

  • .secs syntax: template<Contract> Name : Parent1, Parent2 { ... }
  • Compiler merges channel source declarations (union of all parents + child)
  • Compiler chains lifecycle methods (parent methods in declaration order, then child)
  • Child field values override parents; unset fields inherit from leftmost parent
  • Pre-activation guards ANDed across all parents + child
  • reads directives merged (union of all dependency metadata)
  • Output: single flat TemplateEntry — engine unchanged

If ever implemented, start with Vic3's model

Single-level defaults from a group/category, not full multiple inheritance:

template_group FarmGroup
{
reads { Fertility }
int GoldCost = 10;
int WoodCost = 15;
}

template<Building> Farm : FarmGroup
{
channel int FoodOutput { return (int)(5 * @district.resolve(Fertility)); }
// inherits GoldCost = 10, WoodCost = 15 from group
}

template<Building> IrrigatedFarm : FarmGroup
{
channel int FoodOutput { return (int)(8 * @district.resolve(Fertility)); }
int GoldCost = 25; // overrides group default
}

Conditional Tooltip Text

Status: Deferred — the localization system already handles conditional description text. Structured effect line visibility isn't something EU5 does either.

What it is

Modifier tooltips show all effect lines unconditionally. The proposal is to add conditional visibility ("show this line only when the modifier is Active") or context-dependent text ("show different text based on stat value").

What SECS already has

LocalizationResolver already supports conditional text branching in localization strings via RichTokenType.Conditional with CompareOp (gte, lte, gt, lt, eq, neq). Description paragraphs at the bottom of tooltips can already show different text based on live stat values. Data bindings ([settlement.Morale]) resolve live values with format codes (+, %, .N).

What EU5 actually does

EU5 modifier effect lines are always shown — there is no conditional visibility per effect line. Conditional display is on effects in event options (via if/else blocks), not on modifier declarations:

  • custom_tooltip — replaces auto-generated text with a localization key
  • hidden_effect — effects execute but generate zero tooltip text
  • show_as_tooltip — effects display in tooltip but don't execute (preview)
  • triggered_desc — selects between localization strings for narrative text

None of these apply to modifier stat lines. A modifier like +5 Morale, -10% Production always shows both lines.

Why it's deferred

  1. Conditional effect lines create confusing UI. If a modifier's Defense line appears/disappears based on Garrison value, the player can't understand what the modifier does by reading it. Static declarations are easier to reason about.

  2. Localization conditionals already cover the common case. The description paragraph can say "While garrison is strong, this provides significant defense" vs "Garrison is too weak for fortification bonus" — this is context-dependent text without hiding effect lines.

  3. hidden_effect/show_as_tooltip belong in events/activities, not in the tooltip builder. SECS events build tooltips from localization text, not from command introspection. These patterns should be designed as part of the event presentation system.

What would change if implemented

  • Add optional Func<EntityHandle, bool>? condition on TooltipBlock for conditional visibility
  • TooltipBuilder evaluates conditions at build time, skipping blocks that don't pass
  • New .secs syntax: tooltip_if condition { ... } in modifier descriptions
  • hidden_effect { } and show_as_tooltip { } blocks in event option syntax
  • Rendering layer filters blocks by condition before display