08 — Collections and Propagation
This document specifies the engine + compiler primitives that any SECS-built game reuses for scope-bound collections, propagating modifiers, aggregate channels, prev-tick channel snapshots, structural tags, and the system-execution model (phases and frequencies). It is deliberately game-agnostic — every primitive here must hold for a CK3-style realm sim, a dungeon crawler, a sci-fi 4X, and Valenar alike.
The five new engine-level primitives introduced are: ScopedList<T>, ScopedDictionary<TKey, T>, virtual binding propagation, LINQ-flavored aggregate channel sources, and prev-tick channel snapshots. The compiler-level vocabulary features are: structural tags (declared as plain C# TagId constants, resolved via the compiler, registered into the generated SecsRegistry tag catalog, and evaluated by the engine as opaque hash set-membership against that registry-backed vocabulary), and slot recognition for phase = ... and frequency = ... assignments inside system bodies (system_phase / system_frequency merge slots). Top-level phase and cadence declarations are plain C# helper classes — not SECS keywords. Standalone prev_tick(channel) is not source syntax; current generated/runtime code reads previous values through the host bridge ReadPrevTick* methods.
Valenar-specific consumers of these primitives live in the Valenar docs tree, especially examples/valenar/docs/systems/gd-resources-items-crafting.md and examples/valenar/docs/systems/gd-settlement-buildings-economy.md. Treat those as downstream game-specific consumers, not part of the first-pass SECS contract path.
The two layers of this document:
| Layer | Owns | Sections |
|---|---|---|
Engine (src/SECS.Engine/) | Generic, game-agnostic mechanisms — collections-as-scope, propagation, aggregation, prev-tick, set-membership predicates, tick & phase execution model | Parts 1, 2, 4, 5 |
Compiler (secs-roslyn) | Slot recognition for system_phase / system_frequency / system_execute_body inside system bodies; tag-alias short-name resolution in tags = ... and has_tag ... predicates; structural predicates | Parts 3, 5 |
A future SECS-built game reuses these layers unchanged and replaces only its game layer.
Relationship to prior docs:
- Extends
01-world-shape.md: the barecollectionkeyword is replaced by typedScopedList<T>andScopedDictionary<TKey, T>declarations. Collections remain structurally scope-bound (only declared insidescopeblocks), but gain entity identity, modifier surface, aggregate channels, and lifecycle hooks. The scope itself is anonymous — addressed via field path, not a separately named scope. - Extends
03-channels-and-modifiers.md: addspropagates_toclause and virtual binding materialization to modifier declarations; adds aggregate channel source kind; adds previous-tick bridge reads (ReadPrevTick*) for channels marked withtrack_prev = true. - Extends
07-structured-template-data-and-callables.md: provides the field-type mechanism that game-level structured types (e.g. Valenar'sResourceIO) build on. - Adds the SECS built-in
TemplateIdstrong type — wraps the underlyingulongtemplate-id hash for compile-time-checked keys.
Prerequisites
01-world-shape.md— scope, field,walks_to, the originalcollectionkeyword (now superseded byScopedList<T>/ScopedDictionary<TKey, T>).03-channels-and-modifiers.md— modifiers, bindings, stacking, triggers, 6-phase pipeline.07-structured-template-data-and-callables.md— structured template fields andSecsTypeRef.
For the modding / slot-merge architecture (inject/replace operations, semantic slot schema, merge rules, load order), see 06-overrides-and-modding.md.
Part 1: Scoped Collections (engine-level primitive)
Why the bare collection keyword is replaced
01-world-shape.md § collection defines collection <Name>; as a metadata pointer — a hash for type-checking with no runtime entity, no channels, no modifier surface. This means:
- A "Storehouse boosts ALL resource capacity" effect requires N separate
add_modifiercalls. Adding a new resource forces editing the Storehouse. - Aggregate channels ("total capacity") require host-side iteration with no caching.
- No lifecycle hooks on the collection itself.
- No way to target the collection as a unit for UI, tooltips, or modifier inspection.
- The element type was inferred from
walks_tocontext, ambiguous when multiple scopes could walk to the same collection.
The replacement gives collections a real entity per scope-level field, with type-system distinction between keyed and ordered semantics, while staying inside the structural rule that collections only appear inside scope blocks.
The two collection types
ScopedDictionary<TKey, T> // keyed lookup, unique per key, supports [key] indexer
ScopedList<T> // ordered, allows duplicates, no key-based lookup
Both names follow C# stdlib conventions:
ScopedDictionary<TKey, T>mirrorsSystem.Collections.Generic.Dictionary<TKey, TValue>in arity and semantics.ScopedList<T>mirrorsSystem.Collections.Generic.List<T>in arity and semantics.
The Scoped prefix marks SECS-specific divergence:
- Engine-managed lifecycle (host code does not call
.Add()/.Remove()directly; membership flows through scope activation/deactivation) - Each entry is an entity scope, not a value
- The collection itself is an anonymous entity scope with its own modifier surface and aggregate channels
- Save/load through the entity-graph path, not value serialization
The prefix uses C# adjectival convention (IReadOnly..., Frozen..., Immutable...) — it marks a constrained variant whose name otherwise matches the unconstrained C# stdlib type. This keeps the C# mental model loaded correctly (Dictionary = keyed, List = ordered) while signaling that engine semantics apply.
TemplateId built-in type
Most SECS entity collections are keyed by template id. A SECS built-in strong type wraps the underlying ulong template hash:
public readonly record struct TemplateId(ulong Value);
TemplateId is the canonical key type for the common case ScopedDictionary<TemplateId, T>. Other key types are user-defined SECS types (records, structs, scope handles, primitive aliases).
The strong type prevents passing the wrong ulong (entity id vs template id vs modifier id vs channel id — all ulong in raw form) where a key is expected. Compile errors instead of runtime mismatches. C# 11+ record structs are zero-cost wrappers; this type compiles to the same bytes as a raw ulong at runtime.
Anonymous collection scopes
(The examples below use Valenar scope names like Settlement and Resource for concreteness; the mechanism is game-agnostic.)
A collection field declaration creates an anonymous scope entity owned by the parent. The scope has:
- An
EntityHandle - A
ModifierBindingStorefor modifiers attached to the collection - A
ChannelCachefor aggregate channels declaredon Parent.FieldName - Lifecycle hooks via the parent scope's contract methods (e.g.
OnResourcesAdded(Resource r))
The collection scope is addressed via the parent's field path: Settlement.Resources is the anonymous scope at that field. There is no separately-named ResourceCollection scope to declare, instantiate, or maintain. This matches C# practice — public Dictionary<int, Foo> Bar { get; set; } doesn't require a separate BarCollection type to exist.
Declaration syntax
scope Settlement
{
walks_to Settlement;
ScopedDictionary<TemplateId, Resource> Resources;
int Population;
int Stage;
}
scope Location
{
walks_to Province;
walks_to Location;
ScopedList<Building> Buildings;
}
Compiles to:
// Declarations.cs — anonymous collection scope registration (one entry per field)
public static readonly CollectionDeclaration[] Collections =
[
new()
{
ParentScopeId = H.Settlement,
FieldName = "Resources",
FieldHash = H.Settlement_Resources,
Kind = CollectionKind.Dictionary,
KeyType = SecsTypeRef.From<TemplateId>(),
ElementScopeId = H.Resource,
},
new()
{
ParentScopeId = H.Location,
FieldName = "Buildings",
FieldHash = H.Location_Buildings,
Kind = CollectionKind.List,
KeyType = null,
ElementScopeId = H.Building,
},
];
FieldHash is the addressable identity of the anonymous collection scope. Aggregate channels and modifier targeting both resolve to this hash via the field-path syntax (on Settlement.Resources, @Settlement.Resources.add_modifier ...).
Indexed access
ScopedDictionary<TKey, T> supports [key] indexed access with compile-time key-type checking:
@Settlement.Resources[Iron].add_modifier ForgeIronProduction;
var amount = @Settlement.Resources[Wheat].resolve(Amount);
The key (Iron, Wheat) is a TemplateId constant exposed as H.Iron, H.Wheat in Generated/Hashes.cs. The compiler verifies the indexer call against the declared key type — passing an EntityHandle or raw ulong where TemplateId is expected is a compile error.
ScopedList<T> does NOT support [key] access — the type itself doesn't carry a key. Compile error if attempted:
@Location.Buildings[Farm].add_modifier X; // SECS0822: ScopedList<T> does not support indexed access; use .Where() / .First() / iteration
For non-unique collections, use iteration or structural filtering:
foreach building in @Location.Buildings.Where(has_template Farm) { /* ... */ }
@Location.Buildings.First(has_template Storehouse)?.add_modifier X;
Null-handling for collection lookup
For ScopedDictionary collections that pre-spawn one entity per registered template at parent activation: lookup never fails during normal gameplay because every key has a corresponding entity from creation onward.
For the generic primitive (collections that allow runtime add/remove): [key] returns a nullable handle. Silent no-op with debug-mode warning is the default when a modifier targets a null handle. Loud runtime errors in modifier evaluation cascade poorly. Systems that need to branch on existence use an explicit null check.
Performance contract
| Operation | ScopedDictionary<TKey, T> | ScopedList<T> |
|---|---|---|
[key] lookup | O(1) amortized | Compile error |
foreach iteration | O(N) | O(N) |
.Where(structural predicate) | O(N) | O(N) |
.Sum(channel) / aggregate | O(N) per dirty, cached per tick | O(N) per dirty, cached per tick |
| Insertion (engine-managed) | O(1) amortized | O(1) |
| Removal (engine-managed) | O(1) amortized | O(1) |
Host implementation must back ScopedDictionary with a hash map (Dictionary<TKey, EntityHandle>, FrozenDictionary for read-mostly, NativeHashMap on Burst-targeted hosts). Host implementation backs ScopedList with a list (List<EntityHandle>, NativeList, etc.). The contract is the Big-O guarantee, not the specific container — different hosts may pick different backings as long as they satisfy it.
Aggregate channels — LINQ-flavored
Aggregate channels live on the parent scope, addressed at the collection field path, computed from child channels via LINQ-flavored expressions:
channel int TotalCapacity
{
on Settlement.Resources;
kind = Contributed;
source = Children.Sum(Capacity);
}
channel int FoodCapacity
{
on Settlement.Resources;
kind = Contributed;
source = Children.Where(has_tag Food).Sum(Capacity);
}
channel int ResourceCount
{
on Settlement.Resources;
kind = Contributed;
source = Children.Count();
}
Supported aggregation methods on Children:
.Sum(channel)— sum of resolved child channel values.Min(channel),.Max(channel)— min / max of resolved values.Average(channel)— integer average.Count()— count of children.Count(predicate)— count children matching a structural predicate.Where(predicate)— filtered subset; the predicate must be structural (chainable with the above)
Predicate forms (structural only, see Part 3 on tags):
has_tag <TagName>— child template declares the taghas_tag <Tag1>, <Tag2>— OR across tagshas_template <TemplateName>— child's template matcheshas_contract <ContractName>— child satisfies the contract
Aggregate channels are cached per tick in ChannelCache. Dependencies registered via the source expression dirty the aggregate when child channels change OR when children join/leave the collection. Recomputation is amortized across all readers in the same tick.
walks_to for collection children
A child entity's walks_to chain explicitly names every reachable scope. walks_to is NOT automatically transitive — each level is declared:
scope Resource
{
walks_to Settlement.Resources; // direct parent: the anonymous collection scope
walks_to Settlement; // explicit transitive: needed for @Settlement reads
walks_to Resource; // self-walk for resolve() within own root
}
The Settlement.Resources walks_to declaration registers the child against the anonymous collection scope at H.Settlement_Resources. The Settlement walk is needed for modifiers that read @Settlement.Population etc. from a Resource context. Without the explicit declaration, the resolver would fail to walk past the collection scope.
This explicit-only model is the existing 01-world-shape.md rule; it is reaffirmed here for clarity since the prior 08-doc draft suggested otherwise.
Part 2: Virtual Binding Propagation (engine-level primitive)
The propagates_to clause
A modifier declaration may include propagates_to children to indicate that, when bound to a ScopedDictionary or ScopedList scope, the effect materializes as virtual bindings on each child entity. Default (propagates_to = none) means the modifier affects only the collection scope's own channels.
The following examples use Valenar building names (Storehouse, Granary) and effect names; substitute your game's vocabulary as needed.
modifier StorehouseCapacityBoost
{
translation = "Storehouse Capacity";
stacking = stackable;
propagates_to children;
Capacity += 100;
}
modifier GranaryFoodBoost
{
translation = "Granary Food Storage";
stacking = stackable;
propagates_to children where has_tag Food;
reads { Level }
Capacity += 50 * resolve(@owner.Level);
}
Compiles to:
new()
{
ModifierId = H.StorehouseCapacityBoost,
Translation = "Storehouse Capacity",
Stacking = StackingPolicy.Stackable,
PropagatesTo = PropagationTarget.Children,
Effects =
[
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Capacity, Mode = EffectMode.Additive, IntValue = 100 },
],
},
new()
{
ModifierId = H.GranaryFoodBoost,
Translation = "Granary Food Storage",
Stacking = StackingPolicy.Stackable,
PropagatesTo = PropagationTarget.Children,
PropagationPredicate = new() { Kind = PredicateKind.HasTag, Id = H.Tag_Food },
Effects =
[
new() { TargetKind = ModifierEffectTargetKind.Channel, TargetId = H.Capacity, Mode = EffectMode.Additive, FormulaId = H.Formula_GranaryFoodBoost },
],
},
Why virtual bindings: Materialization keeps the channel resolver unchanged — it sees only ordinary bindings on child entities. All propagation complexity lives in binding management (add/remove), not in the hot resolution path. Live runtime tools that inspect ModifierBindingStore can treat virtual bindings as first-class rows, but durable save/load does not work unchanged yet: the current runtime has materialization/removal only, and reconstruction needs a future modifier-binding snapshot protocol.
The five propagation rules
The rules below illustrate semantics with Valenar building/modifier names (Storehouse, Granary, StorehouseCapacityBoost, GranaryFoodBoost, Settlement.Resources). The mechanism is uniform across any game's vocabulary.
Rule 1: Owner preservation
A virtual binding preserves the original owner from the source binding on the collection.
When a Granary (Building entity) adds GranaryFoodBoost to Settlement.Resources, the virtual bindings on child Resources all have OwnerId = Granary entity. Dynamic effects using @owner (e.g. resolve(@owner.Level)) evaluate against the Granary, not the child Resource.
This falls out naturally: virtual bindings copy OwnerId from their source binding. The resolver already evaluates @owner against binding.OwnerId.
Rule 2: Stacking on collection; key is (modifier_id, owner_id)
Stacking policy (stackable, reject, refresh) is evaluated on the collection entity when a new binding arrives. The stacking key is (modifier_id, owner_id), not modifier_id alone.
Two Storehouses (two different owner entities) each adding StorehouseCapacityBoost:
- Stacking key for Storehouse 1:
(H.StorehouseCapacityBoost, entity_7) - Stacking key for Storehouse 2:
(H.StorehouseCapacityBoost, entity_12) - Different keys → both accepted regardless of stacking policy.
One Storehouse adding the same modifier twice (e.g. from two code paths):
- Same key → stacking policy applies (
rejectrejects,refreshrefreshes,stackablestacks).
Once a binding is accepted on the collection, its virtual bindings materialize on all qualifying children. Children do NOT independently re-evaluate stacking for virtual bindings. The collection is the gatekeeper.
Rule 3: Lifecycle mirrors the source
Virtual bindings have no independent lifecycle:
- Duration / decay: tracked on the source binding only. Source expires → all its virtuals die.
- Removal: removing the source from the collection removes all its virtuals.
- Trigger (
whileclause): evaluated on the source binding. Source goes inactive → virtuals go inactive. - No independent timers. Virtual bindings are projections, not autonomous entities.
Rule 4: Structural-only where predicates
The propagates_to children where ... filter admits only structural predicates that cannot change at runtime:
| Legal predicate | Meaning |
|---|---|
has_tag Food | Child's template declares the tag (immutable) |
has_tag Mineral, Arcane | OR across tags |
has_template Wheat, Barley | Child's template is one of these |
has_contract Resource | Child satisfies the contract |
Channel-based or value-based predicates are a compile error. The compiler rejects where Level > 5 or where Amount < Capacity with diagnostic:
SECS0802: propagates_to filter must be structural (has_tag / has_template / has_contract). Use a conditional reads {} clause in the effect body for value-based gating.
Correct pattern for value-conditional propagated effects:
modifier OnlyAffectsSaturatedResources
{
propagates_to children;
reads { SaturationRatio }
FlowIn *= resolve(@target.SaturationRatio) > 80 ? 0 : 1;
}
Two surfaces, two purposes: structural filter at attach time (static, evaluated once), value gate at resolution time (dynamic, evaluated every read).
Filter evaluation timing: once at virtual binding creation (when modifier is added to collection, or when a new child joins). Since structural predicates cannot change at runtime, re-evaluation is never needed.
Predicate form: structural operators only
SECS supports one predicate family in collection filters: structural operators whose truth can be determined from immutable template metadata and contract membership.
| Form | Example | Where valid | When evaluated | Engine surface |
|---|---|---|---|---|
Operator (has_tag X, has_template X, has_contract X) | Children.Where(has_tag Food).Sum(Capacity) | propagates_to where, aggregate .Where(...), Children.Count(predicate) | Once at virtual binding creation for propagation; during aggregate recomputation for aggregate filters | (FilterKind, hash) pair, evaluated by structural predicate evaluator |
propagates_to where, aggregate .Where(...), and Children.Count(predicate) all reject C# lambdas and arbitrary value expressions (b => b.Level > 5, resolve(Food) > 0, Amount < Capacity). The compiler must not lower these as runtime closures. Value-dependent collection filtering is deferred until a separate dependency-tracked aggregate-filter design is accepted.
Why structural-only: virtual bindings are materialized eagerly at attach time and never re-evaluated (Rule 4), while aggregate filters share the same declared structural predicate table for deterministic binding, generated catalogs, and explicit diagnostics. Value predicates need dependency tracking that does not exist in the committed aggregate-filter contract.
Recommended usage:
- Structural intent (template, tag, contract membership): use operator form — it is the only current predicate surface.
- Value-based intent in aggregates: NOT supported as
.Where(...)orCount(predicate)— defer to a future dependency-tracked aggregate-filter design, or move the runtime value gate into the aggregate source that owns the read. - Value-based intent in propagation: NOT supported as a filter — refactor to attach unconditionally and gate via value expression in the effect body.
Rule 5: HardOverride priority — closer-scope-wins
Within additive and multiplicative phases, ordering between propagated and direct modifiers does not matter (both operations are commutative).
For HardOverride, priority follows scope proximity:
Direct binding on child wins over propagated binding from parent collection. Within the same scope distance, last-applied wins.
This generalizes cleanly: if multi-hop propagation is added later (descendants from grandparent), priority is direct > parent-propagated > grandparent-propagated. Closer scope wins. Same specificity principle as CSS cascade — more specific selector beats less specific regardless of source order.
Materialization mechanics
Eager materialization
When a propagating modifier is bound to a collection scope, the engine immediately creates virtual bindings on all qualifying children. This is eager, not lazy.
At small-game scale (a few dozen children × ~5-20 propagating modifiers = a few hundred virtual bindings), this is trivial for ModifierBindingStore. Lazy materialization (create virtual on first child channel read) is a known optimization for megamod scale (200+ children × 50+ propagating modifiers). Deferred until scale pressure appears. The semantic contract is identical either way.
New child joins collection
- Engine scans all bindings on the parent collection where
Propagation.Mode != None. - For each, evaluates structural filter against the new child's template / tags / contract.
- Creates virtual bindings for those that pass.
- Dirties any aggregate channels on the collection scope.
Child leaves collection
- Removes all virtual bindings on that child whose source is a collection-propagated binding.
- Dirties aggregate channels on the collection scope.
- Source bindings on the collection remain unchanged.
Self-channels vs propagation disambiguation
A modifier bound to a collection scope without propagates_to (default = none) modifies the collection scope's own channels only. A modifier with propagates_to children materializes on children and does NOT affect the collection's own channels. These are mutually exclusive per binding.
If an author wants both (boost the collection's own channel AND propagate to children), they declare two separate modifiers. In practice this is rare — the collection's aggregate channels are computed FROM children, so boosting children automatically updates the aggregate.
Save/load: virtual bindings are derived state
Virtual bindings are never persisted to the save file. They are derived runtime state materialized from real source bindings.
Current runtime status: materialization and removal exist; durable
modifier-binding persistence does not. A load cannot currently
reconstruct virtual bindings because ModifierBindingStore has no
Snapshot/Restore surface for real source bindings and does not persist
stable creation-order / source-binding metadata.
Target future reconstruction contract:
- Deserialize all entities and real source bindings through a future
ModifierBindingStore.Snapshot/Restoreprotocol. - Persist enough stable metadata on each real source binding to preserve creation order and source identity across save/load.
- For each collection scope entity, scan restored real bindings for
Propagation.Mode != None. - Materialize virtual bindings on current children using the same creation logic as runtime.
Reconstruction must be deterministic in order. HardOverride
semantics depend on application order. The future protocol must iterate
source bindings in stable order, for example by
(source_owner_id, binding_creation_tick) or an equivalent stable
source-binding id. That ordering key is not persisted by the current
runtime; adding it belongs with ModifierBindingStore.Snapshot/Restore,
not with virtual-binding materialization alone.
Part 3: Tags (compiler-level vocabulary, engine-level evaluation)
Tags are a registry-backed vocabulary feature with compiler-owned source resolution and engine-level hash membership checks. The game declares typed identities as plain C# TagId constants; generated output registers the collected catalog through SecsRegistry.RegisterTag(...); template entries carry opaque ulong hashes for fast predicate evaluation. This split keeps the engine game-agnostic: a future SECS-built game can declare an entirely different Tags class (Cursed, Holy, Fire, ...) without changing the membership-check algorithm.
The three layers
| Layer | Owns | Knows |
|---|---|---|
| Compiler/generated | plain C# TagId constants as vocabulary inputs, name resolution, FNV-1a hash emission to Hashes.cs, tags = ... template body parsing, SecsRegistry.RegisterTag(...) catalog emission, undeclared-tag diagnostics | What names exist, how to resolve them to hashes, and which registered tags are valid |
| Engine | registered tag catalog plus template.Tags.Span.Contains(hash) set-membership check during predicate evaluation | Which tag ids are registered, and that templates carry an opaque ReadOnlyMemory<ulong> |
| Game | Which tags exist (Food, Mineral, Magical, ...), what they mean | The semantics |
Tag declarations: plain C#, not a SECS keyword
Tag identifiers are declared as plain C# static readonly constants using the canonical-identity-string convention from 06-overrides-and-modding.md § 4.2. They are not declared with a tag <Name>; SECS keyword — that form is rejected per 06-overrides-and-modding.md § 19.4 Tag identities stay plain C#.
// Content/common/tags.secs (plain C# inside a .secs file)
namespace Valenar;
public static class Tags
{
public static readonly TagId Food = TagId.Create("valenar:tag/food");
public static readonly TagId Perishable = TagId.Create("valenar:tag/perishable");
public static readonly TagId Mineral = TagId.Create("valenar:tag/mineral");
public static readonly TagId Strategic = TagId.Create("valenar:tag/strategic");
public static readonly TagId Currency = TagId.Create("valenar:tag/currency");
public static readonly TagId Magical = TagId.Create("valenar:tag/magical");
}
TagId.Create(canonicalString) computes the FNV-1a-64 hash of the canonical identity string at startup (or at codegen time if the compiler folds it). The canonical string prevents accidental cross-mod collisions on common names like Food or Market (06 § 4.2).
There is no TagDeclaration[] array in Generated/Declarations.cs; tag identity starts in the plain C# TagId constants and may be mirrored as generated hash constants for emitted-code convenience. Generated output registers the collected tag catalog through SecsRegistry.RegisterTag(...), giving analyzers and runtime validation one registry-backed vocabulary while TemplateEntry.Tags keeps using raw hashes for fast membership checks. Once typed ids and hash mirrors are available as Tags.Food / H.Tag_Food, the compiler resolves short-name references in tags = ... template bodies and has_tag ... predicates against this catalog.
Tags applied to templates (SECS slot template_tags)
A template applies tags via the tags = Tag1, Tag2; field in its body — this is the SECS slot template_tags (per 06 § 8.2). Short-name identifiers (Food, Perishable) are resolved by the compiler against the game's Tags class; alternatively the qualified form Tags.Food is always unambiguous. The compiler emits the resolved hashes as a ReadOnlyMemory<ulong> Tags initializer on the template's TemplateEntry:
template<Resource> Wheat
{
tags = Food, Perishable;
channel int Capacity = 200;
}
template<Resource> Iron
{
tags = Mineral, Strategic;
channel int Capacity = 100;
}
Lowers to:
// In the generated TemplateEntry for Wheat:
new TemplateEntry
{
TemplateId = H.Wheat,
Tags = new ReadOnlyMemory<ulong>(new ulong[] { H.Tag_Food, H.Tag_Perishable }),
// ... other template metadata
}
Tags are immutable at runtime: they come from the template at registration time and cannot change. This is what makes propagates_to children where has_tag Food safe to evaluate once at attach time (Rule 4 above) and Children.Where(has_tag Food) safe to use in aggregate channel sources without dependency tracking on tag changes.
Tags apply to any template kind, not just one specific kind. Buildings, Sites, Features, Provinces, characters — any template can declare a tags = ... list, and any predicate that evaluates has_tag X works uniformly across template kinds:
template<Building> Forge { tags = Industrial, FireHazard; }
template<Site> OldBattlefield { tags = Haunted, Historic; }
template<Province> CoastalProvince { tags = Coastal, Trading; }
The engine's set-membership check is uniform — template.Tags.Span.Contains(hash) doesn't care what template kind the entity belongs to.
Engine-level: predicate evaluation (the only engine surface)
The engine's contribution is one method that handles structural predicates uniformly:
private static bool EvaluateStructuralPredicate(EntityHandle child, PropagationFilter filter)
{
var template = registry.GetTemplate(child.TemplateId);
return filter.Kind switch
{
FilterKind.Tag => template.Tags.Span.Contains(filter.Hash),
FilterKind.Template => child.TemplateId.Value == filter.Hash,
FilterKind.Contract => template.Contracts.Contains(filter.Hash),
};
}
Used by:
ModifierBindingStorewhen materializing virtual bindings against a structural filter (Part 2, Rule 4)ChannelResolutionwhen evaluatingChildren.Where(has_tag Food).Sum(Capacity)aggregate channel sources (Part 1)PolicyExecutorwhen evaluating activity selectors that useSelectorSource.FromTags(see04-behavior.md)
Tags are declared as typed TagId.Create("namespace:tag/name") identity helpers in the game's Tags static class or equivalent vocabulary input. Generated output registers the collected tag catalog through SecsRegistry.RegisterTag(...), so runtime/registry validation is registry-backed. TemplateEntry.Tags still stores tag hashes for fast set-membership, and structural predicate evaluation carries those hashes in (FilterKind, hash) pairs alongside template and contract filters.
Current fixture status: Valenar's live source/generated provenance covers template tags only. Activity tags and policy from tags selector lowering remain committed compiler/runtime contracts, but their executable Valenar provenance is deferred to a scoped tag-fixture wave that lands .secs, Generated, and tests together. Valenar also does not currently have a non-fabricated gameplay use for propagates_to children where has_tag ... or Children.Where(has_tag ...).Sum(...); those two source fixtures remain deferred. The runtime support is still committed and tested through PropagationFilter.ForTag(...), StructuralPredicate, PropagationDispatcher, AggregateChannelSource.Predicate, and ChannelResolution.
Why this layering
- Engine stays game-agnostic. Adding a
TagId.Create("game:tag/cursed")constant for a dungeon crawler requires zero engine changes. Same predicate evaluation method; new hash. - Hash collision is surfaced at canonical-string level. FNV-1a-64 collisions between distinct canonical identity strings are errors (
SECS0601). The canonical-string convention (valenar:tag/food) prevents accidental cross-mod hash collisions on common short names. - Mod tooling reads registered vocabulary, not ad hoc hashes. A debug inspector that wants to enumerate all declared tags reads the registered tag catalog emitted from the game's
Tagsstatic class or canonical manifest. - Future SECS features that look like "engine concepts" but are actually game/compiler vocabulary —
category,class,kind— should follow this same pattern. The test: "does the engine need to know what this token MEANS, or just that two tokens match?" If the latter, it's plain C# + compiler name-resolution, not a new SECS keyword.
Game-level: where tags actually live
The actual tag declarations and the semantic meaning of each tag live in the game's content tree, not in the SECS engine docs. Each game built on SECS declares its own vocabulary in its own content tree; the engine and compiler are unchanged across games.
Part 4: previous-tick snapshots (engine-level primitive)
Some systems need to detect transitions in channel values across ticks. The live engine snapshots tracked channels and exposes the previous-tick value through the host bridge ReadPrevTick* methods. Standalone prev_tick(...) is not a SECS-recognized expression form, and ctx.PrevTick(...) is not a committed live runtime API (per 06 § 9.5).
host.ReadPrevTickInt(resource, H.Amount)
Example generated/runtime bridge-read shape:
foreach (var resource in resources)
{
var amount = resolver.ResolveIntFast(resource, H.Amount);
var prevAmount = host.ReadPrevTickInt(resource, H.Amount);
if (prevAmount > 0 && amount == 0) { /* fire on_action on_resource_depleted */ }
if (prevAmount == 0 && amount > 0) { /* fire on_action on_resource_replenished */ }
}
The compiler does not treat previous-tick reads as a special source expression today. A future compiler/analyzer may recognize a well-known API for dependency tracking if that API is committed, but that does not create a standalone SECS expression form.
Storage
The engine snapshots prev-tick values for channels marked track_prev = true in their declaration. This field is a normal channel-block field, same shape as min = 0:
channel int Amount
{
kind = Accumulative;
name = "Amount";
source = Resource.Amount;
min = 0;
track_prev = true; // engine maintains prev-tick snapshot
}
Memory cost: ~8 bytes per (entity × tracked channel). At small-game scale (dozens of entities × a handful of tracked channels), trivial.
Snapshot timing: at the END of each tick, after all systems in all phases have run. The next tick's ReadPrevTick* bridge call reads this snapshot. Channels not marked track_prev = true, or tracked channels whose required snapshot is missing, should throw at runtime; a future analyzer/compiler check can flag the same misuse earlier as a quality-of-life diagnostic.
Part 5: System Execution Model — Phases and Frequencies (engine + compiler)
The engine's execution model has three rooted phases (Pre, Main, Post) and runs systems on a tick-driven schedule with per-system frequency. Game content declares sub-phase names and cadence constants as plain C# helper classes — not as SECS keywords. The compiler owns slot recognition for the phase = ... and frequency = ... assignments inside system bodies (system_phase / system_frequency merge slots per 06 § 8.1). The engine sees only the resolved PhaseDeclaration and TickRate values.
This section follows the same layering rule as Part 3 (tags): the engine sees resolved data; the game declares vocabulary in plain C#; the compiler recognizes named slots in SECS declarations.
The three layers
| Layer | Owns | Knows |
|---|---|---|
| Engine | Three rooted phases (Pre / Main / Post), tick counter, frequency-gate evaluation (if currentTick % N == 0), per-phase ordered system list | PhaseDeclaration / TickRate struct values; the three rooted SystemPhase enum values and their integer sort keys |
| Compiler | system_phase / system_frequency / system_execute_body slot recognition inside system bodies; phase = X; and frequency = Y; slot-assignment syntax | The slot schema for system declarations; that phase and frequency are slot-merge targets, not arbitrary C# fields |
| Game | Which sub-phases exist (Phases.Production, Phases.Edge, ...), which cadences exist (Cadence.Daily, Cadence.Weekly, ...); their domain meaning | The PhaseDeclaration and TickRate values exposed through plain C# static classes |
Game-level: phase declarations (plain C#)
Game content declares sub-phase names as plain C# static readonly fields in a normal C# class inside a .secs file. The canonical-identity-string convention from 06 § 4.2 is used so that mod-added phases don't collide:
// Content/common/phases.secs (plain C# inside a .secs file)
namespace Valenar;
using SECS.Abstractions.Pipeline;
public static class Phases
{
public static readonly PhaseDeclaration Production =
PhaseDeclaration.Create("game:phase/production", SystemPhase.Main, 1);
public static readonly PhaseDeclaration Flow =
PhaseDeclaration.Create("game:phase/flow", SystemPhase.Main, 2);
public static readonly PhaseDeclaration Decay =
PhaseDeclaration.Create("game:phase/decay", SystemPhase.Main, 3);
public static readonly PhaseDeclaration Maintenance =
PhaseDeclaration.Create("game:phase/maintenance", SystemPhase.Post, 1);
public static readonly PhaseDeclaration Edge =
PhaseDeclaration.Create("game:phase/edge", SystemPhase.Post, 2);
}
PhaseDeclaration.Create produces a value holding (rootedPhase, orderKey, canonicalId). The engine registers these at startup; systems reference them by their C# field name. The top-level phase <Name> : <RootedPhase> { order = N; } SECS keyword is rejected per 06-overrides-and-modding.md § 19.2 Phase declarations stay plain C#.
Game-level: cadence declarations (plain C#)
Cadence constants are declared as static readonly TickRate fields — const int is rejected per 06 § 9.3 because moddable cadence values must remain replaceable:
// Content/common/cadence.secs (plain C# inside a .secs file)
namespace Valenar;
public static class Cadence
{
public static readonly TickRate Daily = TickRate.Days(1);
public static readonly TickRate Weekly = TickRate.Days(7);
public static readonly TickRate Monthly = TickRate.Days(30);
}
The top-level tick_rate <Name> = N; SECS keyword is rejected per 06-overrides-and-modding.md § 19.3 Tick-rate declarations stay plain C#.
System declaration uses assignment-form slot syntax
Inside a system body, phase and frequency are SECS merge slots (system_phase / system_frequency). The assignment form phase = X; and frequency = Y; is the canonical syntax per 06 § 6.3:
system BuildingProductionSystem
{
phase = Phases.Production; // system_phase slot — resolves to (Main, 1)
frequency = Cadence.Daily; // system_frequency slot — resolves to TickRate
method void Execute() { ... }
}
system EmploymentBalanceSystem
{
phase = Phases.Maintenance; // system_phase slot — resolves to (Post, 1)
frequency = Cadence.Weekly; // system_frequency slot — resolves to TickRate
method void Execute() { ... }
}
The field-statement forms phase Production; and frequency Daily; are not valid — they were rejected per 06 § 6.3 because the assignment form is unambiguous and matches the slot-assignment pattern used throughout SECS declarations (tags = ..., track_prev = true, etc.).
Lowers to:
new SystemDeclaration
{
SystemId = H.BuildingProductionSystem,
Phase = Phases.Production.Id,
PhaseOrder = Phases.Production.Order, // = 1
Frequency = Cadence.Daily.Id,
Execute = ...
},
Engine-level: phase-and-frequency execution
The engine's tick loop is uniform regardless of game vocabulary:
foreach (var phase in new[] { SystemPhase.Pre, SystemPhase.Main, SystemPhase.Post })
{
foreach (var system in registry.SystemsForPhase(phase).OrderBy(s => s.PhaseOrder))
{
if (currentTick % system.Frequency != 0) continue;
system.Execute(context);
}
}
The engine does not interpret Daily, Production, or Maintenance. It only knows (SystemPhase, int order) for ordering and int frequency for rate-gating.
Why this layering
- Engine stays game-agnostic. A real-time game built on SECS writes
TickRate.Frames(1)(with frames as ticks). A monthly grand-strategy game writesTickRate.Days(30). Same engine, different game vocabulary. No keyword changes needed. - Phase ordering is unambiguous. Game sub-phases sort within their rooted phase via the
orderinteger embedded inPhaseDeclaration. The engine sorts by integer; it doesn't compare phase name strings. - Mod compatibility. Host-capable expansions can add new sub-phases by adding a new
PhaseDeclaration.Create(...)entry and updating host scheduling configuration. Data-only mods may not add new phases, but may reassign existing systems to existing phases viainject system X { phase = Phases.Maintenance; }— thesystem_phaseslot-merge target is available to all mods.
registry[id] accessor — engine-provided ambient
Systems and formulas read template metadata via the registry accessor:
formula int BuildingPotentialProfit(Building b)
{
var recipe = registry[b.ActiveRecipeId]; // returns TemplateEntry for the bound Recipe
if (recipe == null) return 0;
// ...
}
system BuildingProductionSystem
{
method void Execute()
{
foreach building in Building where ActiveRecipeId != 0
{
var recipe = registry[building.ActiveRecipeId];
// ...
}
}
}
registry is a compiler-emitted ambient identifier that lowers to context.Registry.GetTemplate(templateId):
// Generated lowering of `registry[id]` in a system Execute body:
var recipe = context.Registry.GetTemplate(building.ActiveRecipeId);
context.Registry is the SecsRegistry instance available on every TickContext. The accessor returns TemplateEntry? (nullable) — null if the template id doesn't resolve. Compile error if the resolved type doesn't match the expected contract — registry[recipeId] returns TemplateEntry<Recipe> when used in a Recipe-typed context, allowing field access on the recipe (recipe.Inputs, recipe.Outputs). The accessor is a metadata lookup for any registered template, not a registry_only-only feature. registry_only is the intended metadata-only contract qualifier; today its activation/create enforcement is still a gap and current stand-ins rely on no-op activation plus host/content convention.
In formula bodies, the same accessor is available because formula evaluation also runs through TickContext (formula evaluation has access to the registry but not to the command bus).
Anonymous-collection lifecycle hook naming convention
When a scope declares a collection field, the compiler auto-generates a pair of lifecycle method names that the parent scope's contract may implement:
On{FieldName}Added(T child)
On{FieldName}Removed(T child)
Where {FieldName} is the collection field name on the parent scope and T is the element type.
Example:
scope Settlement
{
ScopedDictionary<TemplateId, Resource> Resources;
ScopedList<Building> Buildings;
}
contract Settlement
{
root_scope Settlement;
method void OnResourcesAdded(Resource r); // auto-generated name
method void OnResourcesRemoved(Resource r);
method void OnBuildingsAdded(Building b);
method void OnBuildingsRemoved(Building b);
}
Resolution rules:
- The compiler computes the hook names at parse time.
- If two collection fields on the same scope produce the same hook name (e.g. fields
ResourceandResourcesboth →OnResourcesAdded), the compiler emitsSECS0840: ambiguous collection lifecycle hook name. Rename one of the fields. - If the contract does NOT declare the hooks, child join/leave events are silently skipped — no warning, no runtime error.
- Hooks fire AFTER the engine completes the scope wiring (child entity exists, walks_to chain valid, virtual bindings materialized) and BEFORE the next system reads it. Same tick.
This is a compiler primitive — it emits dispatch-table entries for the hook method ids. The engine just calls the resolved method id at the right moment via the existing contract-method dispatch path. Engine doesn't know the convention; it sees only "call method X when child joins collection Y."
Deferred optimizations, future extensions, and implementation sequencing for these primitives live in docs/design/FUTURE_WORK.md and the game-specific Valenar implementation docs. Keep this live doc focused on the committed semantics above.
Summary of new primitives
| ScopedList<T> | CollectionDeclaration (Kind = List) | Scope-bound ordered entity collection (mirrors C# List<T>) |
| ScopedDictionary<TKey, T> | CollectionDeclaration (Kind = Dictionary, KeyType set) | Scope-bound keyed entity collection (mirrors C# Dictionary<TKey, TValue> arity) |
| Plain C# TagId.Create("ns:tag/name") | TagId value (FNV-1a-64 of canonical string) | Vocabulary entry for structural classification; generated output registers the tag catalog through SecsRegistry.RegisterTag(...), while TemplateEntry.Tags stores hashes for fast set-membership |
| tags = T1, T2; template body | TemplateEntry.Tags : ReadOnlyMemory<ulong> | Per-template tag set, immutable at runtime; template_tags slot-merge target |
| tags = T1, T2; activity body | SecsActivity.Tags : IReadOnlyList<ulong> | Per-activity classification used by policy selectors and validation |
| consider activities from tags = T1, T2; | SelectorSource.FromTags | Policy selector source matching registered activities by activity tag |
| propagates_to children [where ...] | PropagationClause on ModifierDeclaration | Declares modifier propagation semantics |
| has_tag / has_template / has_contract | PropagationFilter / AggregateFilter | Structural predicates emitted as (FilterKind, hash) pairs for engine evaluation |
| Children.Sum / Min / Max / Count / Average / Where(...) | AggregateChannelSource | LINQ-flavored aggregate channel source declaration |
| on Parent.FieldName channel prefix | Channel declaration's Owner field | Addresses anonymous collection scopes by field path |
| track_prev = true | ChannelDeclaration.TrackPrev | Marks channel for prev-tick snapshot |
| ReadPrevTick* bridge calls | ISecsHostReads.ReadPrevTick* | Reads end-of-previous-tick resolved value in generated/runtime code; not a committed SECS keyword |
| registry_only contract qualifier | future ContractDeclaration.IsRegistryOnly plus current no-op-activate stand-ins | Specified future/current implementation gap: intended to suppress activation and create_entity for metadata-only registry entries once implemented end-to-end |
| Plain C# PhaseDeclaration.Create(canonicalId, SystemPhase, order) | PhaseDeclaration struct registered with scheduler | Game-vocabulary sub-phase name rooted into engine's Pre/Main/Post; declared in game's Phases static class; not a SECS keyword |
| Plain C# TickRate.Days(N) | TickRate struct | Cadence constant; declared in game's Cadence static class; not a SECS keyword |
| phase = X; inside system body | system_phase slot-merge target | Assignment-form slot assignment; X must be a PhaseDeclaration expression |
| frequency = Y; inside system body | system_frequency slot-merge target | Assignment-form slot assignment; Y must be a TickRate expression |
| registry[id] ambient accessor | Lowers to context.Registry.GetTemplate(id) | Template metadata access in system Execute bodies and formula bodies; nullable return |
| Anonymous-collection hook auto-generation | ContractMethodDeclaration per collection field | Emits On{FieldName}Added(T child) and On{FieldName}Removed(T child) method ids; collision diagnostic SECS0840 |