01 — World Shape
A SECS world is a graph of entities bound to contracts that live on scopes with fields, channels, and typed value declarations. Before any template, modifier, system, or method body can be parsed, that skeleton has to exist — the compiler has to know what a settlement is, what fields a location carries, what a Building contract means, which identifiers refer to channel values, and which identifiers name value types such as FeaturePlacementProfile. This document covers the structural declarations that form that skeleton: scopes, scope fields, contracts, top-level fields, type declarations, and top-level channel declarations. Everything downstream (structured template data, per-instance template channels, modifier effect bindings, scope.field reads inside methods) resolves against identifiers introduced here.
These declarations lower to entries in static arrays on Valenar.Generated.Declarations (Valenar's generated namespace shown as the running example; another game would emit <GameName>.Generated.Declarations from the same SECS source). Nothing here executes at tick time; the registry walks these arrays once at host startup. Identifier values are FNV-1a-64 hashes of canonical id strings — see the H.* section below.
Prerequisites
- Reading
00-overview.mdis assumed (theSECS= Scripting Engine C Sharp spelling, theContent/→Generated/lowering story, the host / engine / registry split).
Type declarations
07-structured-template-data-and-callables.md is the authoritative type-model doc. This document owns only the structural prepass consequence: type declarations are collected before contracts, templates, fields, and method bodies are bound, and their metadata is emitted in Declarations.Types[].
SecsScalarType remains the channel-pipeline scalar enum. It is still used by ChannelDeclaration.ValueType because channels are arithmetic channels. It is no longer the generic answer for template fields or callable signatures. Top-level field declarations and scope/contract method signatures use ordinary C# source type syntax; the compiler lowers those source types to SecsTypeRef metadata that can name declared structs, records, enums, arrays, C# collection-shaped values, scope entity references, and template references.
Compiler prepass order: parse type declarations first, then scopes and scope fields, then contracts and callable signatures, then template fields/channels/method bodies. A contract signature such as query FeaturePlacementResult EvaluatePlacement(Location candidate, FeaturePlacementInput input); is illegal until both FeaturePlacementResult and FeaturePlacementInput bind to declared C# source types and therefore to generated SecsTypeRef metadata.
Built-in strong types
The following strong types are SECS built-ins, recognised by the compiler without any struct / record declaration in .secs files:
| Type | Underlying type | Purpose |
|---|---|---|
EntityHandle | ulong | Runtime entity instance id. Identifies a live entity across the host bridge. Never compared against identifier hashes (H.*). |
TemplateId | ulong (record-struct wrapper) | Compile-time–checked template identity. Used as the key type in ScopedDictionary<TemplateId, T> when a collection is keyed by which template built each entry. Prevents accidental mixing of entity ids, channel ids, and template ids at call sites. Full spec: 08-collections-and-propagation.md § TemplateId. |
ChannelId | ulong (record-struct wrapper) | Compile-time–checked channel identity. Used when designer-authored structured metadata needs to point at channels rather than resolve them, such as IReadOnlyList<ChannelId> FeedsChannels on Valenar skill-sheet profiles. Lowers to SecsTypeRef.ChannelId, not SecsTypeRef.UInt64. |
SecsScalarType and SecsTypeRef are compiler/generated-code types, not SECS source-language built-ins; they appear in generated C# output and in this doc's Compiles to: blocks. EntityHandle, TemplateId, and ChannelId are visible as source types in .secs files.
Scope declarations
A scope is a node type in the world graph. Scopes carry fields (host-owned data), typed scoped collections (ScopedList<T> / ScopedDictionary<TKey, T>), and parent links via walks_to. Entities are allocated against a scope; contracts bind to a scope as their root_scope; method bodies walk scopes with name.field expressions. The full typed-collection spec lives in 08-collections-and-propagation.md.
scope with fields
What: A scope declaration introduces a named entity type and its host-owned fields. Field types are int, long, float, double, or bool (compile-time types) and may carry min / max clamps (except bool, which has no clamps — see §"Clamp literal types" below).
SECS (target syntax, Valenar names shown):
scope Settlement
{
walks_to Settlement; // self-walk
// Resources
int Gold;
int Food;
int Wood;
int Metal;
int Stone;
int Production;
// Population
int Population;
int PopulationCap;
// Military
int Garrison;
int Defense;
// Morale
int Morale;
// Stage progression (0 = not founded, 1..5 = active stages)
int Stage;
// The founding Location entity id (0 if not yet founded)
int HomeLocationId;
}
Source: examples/valenar/Content/settlement/scopes.secs.
Compiles to:
public static readonly ScopeDeclaration[] Scopes =
[
new() { ScopeId = H.Settlement, Name = "settlement", ParentScopeIds = [H.Settlement] },
// ...
];
public static readonly ScopeFieldDeclaration[] ScopeFields =
[
// Settlement fields
new() { ScopeId = H.Settlement, FieldId = H.Gold, Name = "Gold", FieldType = SecsScalarType.Int },
new() { ScopeId = H.Settlement, FieldId = H.Food_Channel, Name = "Food", FieldType = SecsScalarType.Int },
new() { ScopeId = H.Settlement, FieldId = H.Wood, Name = "Wood", FieldType = SecsScalarType.Int },
new() { ScopeId = H.Settlement, FieldId = H.Metal, Name = "Metal", FieldType = SecsScalarType.Int },
new() { ScopeId = H.Settlement, FieldId = H.Stone, Name = "Stone", FieldType = SecsScalarType.Int },
new() { ScopeId = H.Settlement, FieldId = H.Production, Name = "Production", FieldType = SecsScalarType.Int },
new() { ScopeId = H.Settlement, FieldId = H.Population, Name = "Population", FieldType = SecsScalarType.Int },
new() { ScopeId = H.Settlement, FieldId = H.PopulationCap, Name = "PopulationCap", FieldType = SecsScalarType.Int },
new() { ScopeId = H.Settlement, FieldId = H.Garrison, Name = "Garrison", FieldType = SecsScalarType.Int },
new() { ScopeId = H.Settlement, FieldId = H.Defense, Name = "Defense", FieldType = SecsScalarType.Int },
new() { ScopeId = H.Settlement, FieldId = H.Morale, Name = "Morale", FieldType = SecsScalarType.Int },
new() { ScopeId = H.Settlement, FieldId = H.Stage, Name = "Stage", FieldType = SecsScalarType.Int },
new() { ScopeId = H.Settlement, FieldId = H.HomeLocationId, Name = "HomeLocationId", FieldType = SecsScalarType.Int },
// ...
];
Source: examples/valenar/Generated/Declarations.cs:9-42. The target types are SECS.Abstractions.Scopes.ScopeDeclaration and ScopeFieldDeclaration (both in the same file at src/SECS.Abstractions/Scopes/ScopeDeclaration.cs:5-22). SecsScalarType is defined in src/SECS.Abstractions/Channels/ChannelDeclaration.cs:3-9 and carries five entries: { Int, Long, Float, Double, Bool } (committed 2026-04-24 — see §"Numeric type set" below). None of Valenar's current scope fields use long / double / bool, but the shape is identical: long Slots lowers to FieldType = SecsScalarType.Long, double Fertility to SecsScalarType.Double, and bool Discovered to SecsScalarType.Bool.
Why this shape: The scope declaration names a node type; the field declarations enumerate the host-owned slots on that node. The engine never mutates these values — it reads them through ISecsHostReads and writes queued Command instances back during SyncDirty. Splitting scopes from fields keeps the declaration arrays flat (one array per table) so the registry can validate both sides independently.
Notes:
min/maxclamps are allowed on scope fields in the.secsgrammar butScopeFieldDeclarationhas noHasMin/Min/Maxsurface. Clamp enforcement is currently spec-only; record the clamp on the corresponding channel declaration where one exists (many scope fields double as thesource =target of a channel).
walks_to
What: walks_to <OtherScope> declares an edge from this scope to another, making other resolvable in expressions on this scope. A walks_to self edge is the conventional self-walk that lets settlement work when this is already a settlement.
SECS:
scope Location
{
walks_to Settlement; // ownership edge via OwnerId; null while wild
walks_to Province; // upward — every location belongs to one province
walks_to Area; // upward (multi-hop via province)
walks_to Region; // upward (multi-hop)
walks_to Location; // self-walk
// Ownership — 0 means wild (unclaimed), otherwise the owning settlement's id.
int OwnerId;
// Designation type (see comment above for values). Drives building gating.
int DesignationType;
// ... other fields elided
}
Source: examples/valenar/Content/locations/scopes.secs.
Compiles to:
new()
{
ScopeId = H.Location,
Name = "location",
ParentScopeIds = [H.Settlement, H.Province, H.Area, H.Region, H.Location],
},
Source: examples/valenar/Generated/Declarations.cs:20.
Why this shape: The engine needs to know every legal target for WalkScope(entity, H.Target). The host owns the actual graph (each location's province membership, owner settlement, etc.); the declaration establishes which edges are legal at compile time so the compiler can reject unresolved walks before generated code exists.
walks_to storage — committed design (2026-04-24, implemented 2026-04-26): ScopeDeclaration stores ParentScopeIds : ulong[]. The compiler emits every declared walks_to line as one element in that array, including self-walks, multi-hop shortcuts, and game-specific ownership edges. Expansion composability requires this shape: if official expansion A adds walks_to Settlement on scope Feature and official expansion B adds walks_to realm on the same scope, the merger unions both edges instead of dropping one into an obsolete single-parent slot.
Host-capable source-set note (walks_to):
- Base game content and official expansions/DLC may declare new
walks_toedges on an existing scope because they can ship the matching host walk implementation. Syntax is the same as base:scope <Name> { walks_to <Other>; }contributes additional edges to the effective base scope'sParentScopeIdsarray. No explicit mod-operation keyword is needed because the source set is adding, not replacing. - Two host-capable source sets that each add the same edge (
walks_to Settlementonscope Feature) produce one entry in the merged array — duplicates are deduplicated by(SourceScopeId, TargetScopeId). No diagnostic. - Host-capable source sets may declare new scopes altogether (
scope OrbitalStation { walks_to Province; }). The new scope'sParentScopeIdsarray is initialised from that declaration as if it were base. - Host-capable source sets may not delete or replace edges declared by base or another host-capable source set. There is no
unwalks_tokeyword and no edge-replacement syntax. Content that needs a reduced edge set declares a new sibling scope with the desired edges. - Third-party data-only mods may not add new
walks_toedges or new scopes under the committed runtime model. They consume the scope graph exported by the base game plus enabled official expansions. A mod body that needs a missing edge must target a game/expansion API that already declares it, not declare host topology itself. SECS0111checks the effective host-capable scope graph. A walk is legal iff base or an enabled official expansion declared the edge before third-party mod content is merged.
ScopedList<T> and ScopedDictionary<TKey, T> (typed scoped collections)
Replaces: The bare collection <Name>; keyword is removed. Any .secs file that previously contained collection Buildings; or collection Provinces; must migrate to one of the two typed forms below.
What: Typed scoped collections are declared inside scope blocks and introduce scope-bound entity collections with explicit element types, an engine-managed lifecycle, modifier surface, and aggregate channel support. The full specification lives in 08-collections-and-propagation.md. This section is a summary with migration guidance.
SECS (new syntax):
scope Settlement
{
walks_to Settlement;
// ... fields elided
int HomeLocationId;
// Typed scoped collection (replaces the bare `collection <Name>;` keyword).
// Settlement keeps its resource bag; the Buildings / Provinces collections
// moved off Settlement when the model was reworked — Buildings now hang off
// Location, and provinces are tracked through reverse OwnerId lookup.
ScopedDictionary<TemplateId, Resource> Resources;
// Methods
query int BuildingCount(TemplateId templateId);
}
Compiles to: Each ScopedList<T> / ScopedDictionary<TKey, T> declaration produces a ScopedCollectionDeclaration row. See 08-collections-and-propagation.md § "Lowering" for the generated struct shape.
Migration: Replace every bare collection <Name>; in .secs source with the typed form. Choose ScopedList<T> for ordered, duplicate-allowing collections. Choose ScopedDictionary<TemplateId, T> (or another key type) for keyed, unique-per-key collections. The prior open questions about typed element inference and bidirectional consistency checking are both resolved by the explicit generic parameter — see 08-collections-and-propagation.md § "Why the bare collection keyword is replaced".
Full spec: 08-collections-and-propagation.md
Methods declared on scopes
What: A scope can declare host-exposed method signatures with typed parameters. Non-void methods are read-only queries implemented by the host read bridge; void methods are command-producing calls implemented by the host command bridge and are only callable where a command context exists.
SECS:
scope Location
{
// ... fields and ScopedList<T> / ScopedDictionary<TKey, T> declarations elided
query int BuildingCount(TemplateId templateId);
method void Reveal();
}
scope Character
{
// ... fields elided
method void DrainStamina(int amount);
method void GainXp(int amount);
method void AdvanceLead(Location target, int amount);
}
Source: examples/valenar/Content/characters/scopes.secs.
Compiles to (target lowering contract):
public static readonly ScopeMethodDeclaration[] ScopeMethods =
[
new()
{
ScopeId = H.Location,
MethodId = H.Scope_Location_BuildingCount_TemplateId_Int,
Name = "BuildingCount",
ReturnType = SecsTypeRef.Int,
ParameterTypes = [SecsTypeRef.TemplateId],
IsQuery = true,
},
new()
{
ScopeId = H.Location,
MethodId = H.Scope_Location_Reveal_NoArgs_Void,
Name = "Reveal",
ReturnType = SecsTypeRef.Void,
ParameterTypes = [],
IsQuery = false,
},
new()
{
ScopeId = H.Character,
MethodId = H.Scope_Character_AdvanceLead_Location_Int_Void,
Name = "AdvanceLead",
ReturnType = SecsTypeRef.Void,
ParameterTypes = [SecsTypeRef.EntityInScope(H.Location), SecsTypeRef.Int],
IsQuery = false,
},
];
The hand-written Valenar Generated/ tree now emits SecsTypeRef declaration rows in examples/valenar/Generated/Declarations.cs and registers them from SecsModule.Initialize. Read-only BuildingCount call sites lower to typed host calls or typed dynamic adapters; the declaration table is the compiler-visible shape that tells generated code which concrete return type to use and makes both read-only and command-producing scope methods type-checkable. IsQuery must agree with the source keyword: query rows must be non-void, and method rows must return SecsTypeRef.Void.
Why this shape: Some queries (counting buildings at a location by template id, for instance) are cheap for the host to answer directly from its indexed data structures and expensive for the engine to reimplement by walking a collection. Some game actions need host-owned side effects that are not channel-pipeline operations, such as spending character stamina, advancing a lead, awarding XP, or revealing a location. Declaring both surfaces on the scope makes the capability discoverable at compile time while keeping the implementation in Host/Bridge/.
Read/write split: A non-void scope method is a pure read for SECS purposes. It can be called from queries, formulas, conditions, CanStart, IsVisible, and other read-only bodies, and it lowers through ISecsHostReads. A void scope method is command-producing. It can be called only from bodies that have a command context: template lifecycle methods, systems/events/actions with ctx.Commands, or another command-producing surface. It lowers through ISecsHostCommands, not through ISecsHostReads, so read-only contexts cannot accidentally mutate host state.
Typed parameter rule: Scope-method parameters are value/helper arguments, not a way to smuggle ambient world context into a template or contract function. query int BuildingCount(TemplateId templateId) is valid because templateId is data for the query. method void AdvanceLead(Location target, int amount) is valid because the target location and amount are explicit action inputs. A signature like query bool CanBuild(Location location, Settlement settlement, Owner owner) is not a SECS contract shape: the caller supplies a scope frame, and the method body reaches world data through root plus declared walks_to sigils such as location / settlement.
Runtime value carrier: Known generated call sites should use concrete C# parameter and return types. Dynamic host/tooling paths may still pass erased value spans, but those spans are validated against SecsTypeRef, not SecsValueKind. Scope-typed parameters such as Location target lower to SecsTypeRef.EntityInScope(H.Location) so the compiler can type-check the source receiver/argument while the runtime bridge still receives a compact entity handle. Non-void scope methods return the declared type; a dynamic adapter may box that value only at an erased boundary.
Method identity rule: Source calls use normal C# overload resolution. Generated ids are owner-and-signature hashes, not name-only hashes. The canonical hash text is scope:<scope-name>.<Method>(<param-types>):<return-type> for scope methods and contract:<contract-name>.<Method>(<param-types>):<return-type> for contract queries/methods. Examples: scope:Character.AdvanceLead(Location,int):void, scope:Location.BuildingCount(TemplateId):int, and contract:Building.CanBuild():bool.
Host-capable extension of scopes
What: The base game and official expansions/DLC may extend an existing scope by adding new fields, new ScopedList<T> / ScopedDictionary<TKey, T> declarations, new scope-method signatures, or new walks_to edges. They may declare entirely new scopes. This is allowed only for host-capable source sets because every such declaration requires matching host storage, child enumeration, walk resolution, or method implementation.
Third-party mod boundary: Third-party mods are data-only under the committed runtime model. They may reference and patch content that uses the exported base/expansion scope graph, but they may not add new scopes, scope fields, ScopedList<T> / ScopedDictionary<TKey, T> declarations, walks_to edges, or scope methods. There is no per-mod host bridge DLL loading. A future engine-owned mod-state system could reopen this, but it must be designed explicitly.
Host-capable additions, no mod operation: A host-capable scope <ExistingName> { ... } block is additive — every field, ScopedList<T> / ScopedDictionary<TKey, T> declaration, method signature, or walks_to line inside it is appended to the effective base scope's declaration. Official expansions do not write replace scope <Name> { ... } for additions; the merger detects the in-scope additive contribution and merges it before third-party mods are compiled.
Field additions:
- A host-capable expansion may add new fields to an existing scope:
scope Settlement { int Piety; }extendssettlementwith one new field. The merger appends a newScopeFieldDeclarationrow withScopeId = H.Settlement,FieldId = H.Piety, and the expansion's host code must allocate storage for that field. - Third-party mods may not add scope fields. A data-only mod that declares
scope Settlement { int Piety; }against an existing exported scope is rejected because the host has no storage surface forH.Piety. - A source set may not change the type of an existing field. A declaration such as
scope Settlement { long Population; }where base declaredint PopulationisSECS0602; field declarations are not overridable. The host-capable source set must declare a sibling field (long PopulationL) instead.
Collection additions: A host-capable expansion may add new typed scoped collections (e.g., scope Settlement { ScopedList<Shrine> Shrines; }) and must supply the corresponding host-bridge integration for the new collection. Third-party mods may iterate existing collections but may not declare new ScopedList<T> / ScopedDictionary<TKey, T> fields. Full extension rules: 08-collections-and-propagation.md § "Host-capable extension".
walks_to additions: Already covered in §"walks_to" above. Host-capable source sets may add new edges to existing scopes; duplicates dedupe; no source set may remove edges. Third-party mods may only use edges already exported by the effective base scope graph.
Method additions: A host-capable expansion may declare a new method signature on an existing scope (scope Location { int WorkshopCount(TemplateId templateId); }) and ship the host implementation as part of that expansion's code. Third-party mods may call existing exported scope methods but may not add or replace scope methods.
Two-source-set field-name clash: When host-capable source set A declares scope Settlement { int Piety; } and host-capable source set B declares the same field on the same scope, both fail SECS0602 unless the game has defined an official expansion ordering/ownership rule outside SECS. The conflict report lists the slot as scope_field keyed by (H.Settlement, H.Piety).
Multi-hop walks on a leaf scope
What: Leaf scopes in a hierarchy (location, feature) declare the full chain of upward walks so expressions can reach any ancestor directly without stepping through intermediates. Today the compiled output keeps only the immediate parent.
SECS:
scope Feature
{
walks_to Location; // upward — every feature sits inside one location
walks_to Province; // upward (multi-hop via location)
walks_to Area; // upward (multi-hop)
walks_to Region; // upward (multi-hop)
walks_to Feature; // self-walk
// Visibility: 0 = hidden (fog), 1 = revealed when player explores nearby.
int Discovered;
// Completion: 0 = active, 1 = finished (for one-time features like dungeons
// or ruins that can be explored and exhausted).
int Cleared;
// Threat flag: 0 = peaceful, 1 = actively threatens nearby tiles.
// Used by host systems to apply DungeonAuraOfDread to adjacent locations.
int Hostile;
}
Source: examples/valenar/Content/locations/features/scopes.secs.
Compiles to:
new()
{
ScopeId = H.Feature,
Name = "feature",
ParentScopeIds = [H.Location, H.Province, H.Area, H.Region, H.Feature],
},
// ...
// Feature scope fields
new() { ScopeId = H.Feature, FieldId = H.Discovered, Name = "Discovered", FieldType = SecsScalarType.Int },
new() { ScopeId = H.Feature, FieldId = H.Cleared, Name = "Cleared", FieldType = SecsScalarType.Int },
new() { ScopeId = H.Feature, FieldId = H.Hostile, Name = "Hostile", FieldType = SecsScalarType.Int },
Source: examples/valenar/Generated/Declarations.cs:21 and :70-72.
Why this shape: The feature declaration explicitly lists every ancestor it needs to reach (province, area, region) so .secs code is self-documenting about which walks are legal. The lowering preserves all declared edges in ParentScopeIds; the host bridge still owns the actual entity lookup for each edge.
Notes:
- The type set for scope fields is
{ int, long, float, double, bool }perSecsScalarTypeatsrc/SECS.Abstractions/Channels/ChannelDeclaration.cs:3-9(committed 2026-04-24 — see §"Numeric type set" below for the full rationale). None of the Valenar scopes declare along/double/boolfield — every binary-valued field (Discovered,Cleared,Hostile,Explored) usesintwith 0/1 semantics. Whether the compiler should allow both and choose, or requireboolfor binary semantics, is an open authoring-convention call; the type system permits either. For now, assumeinteverywhere and document the convention.
Scope-walk correctness (committed 2026-04-24)
What: Every WalkScope(from, H.Target) call the compiler lowers — whether from a target.field expression in a method body, a template Activate body, a formula context walk, or a host-bridge CanBuild helper — must correspond to an edge declared via walks_to <Target> in the source scope's declaration. Undeclared walks are a compile error (SECS0111). The self-walk (walks_to Location; on scope location) counts as a declared edge for WalkScope(loc, H.Location).
SECS: The rule applies to every walk site. Valenar's building templates are rooted at Location, and they need to read settlement-owned resources during CanBuild. That is legal because scope Location declares the ownership edge explicitly:
scope Location
{
walks_to Settlement; // ownership edge via OwnerId; null while wild
walks_to Province; // upward
walks_to Area; // upward (multi-hop via province)
walks_to Region; // upward (multi-hop)
walks_to Location; // self-walk
// ...
}
template<Building> Farm
{
query bool CanBuild()
{
return @Settlement.Stage >= 1
&& @Settlement.Gold >= template_field(Farm, GoldCost, BuildCostContext);
}
}
Compiles to:
public static bool CanBuild(ScopeFrame scope, ISecsHostReads host)
{
var settlement = host.WalkScope(scope.Root, H.Settlement);
if (settlement.IsNull)
{
return false;
}
return
host.ReadInt(settlement, H.Settlement, H.Stage) >= MinStage
&& BuildCostRules.CanPay(Id, scope, host);
}
The host bridge resolves this edge through Location.OwnerId, but that is an implementation detail. The declaration is the contract that lets the compiler accept @Settlement inside a location-rooted template.
Diagnostic — SECS0111:
SECS0111: scope walk from '<FromScope>' to '<ToScope>' is not declared.
Traversable via walks_to from '<FromScope>': { <list of declared targets> }.
If '<ToScope>' should be reachable, add walks_to <ToScope>; to the
scope declaration.
If the walks_to Settlement; line were missing, the Farm case would produce:
SECS0111: scope walk from 'location' to 'settlement' is not declared.
Traversable via walks_to from 'location': { province, area, region, location }.
If 'settlement' should be reachable, add walks_to Settlement; to the
scope declaration.
Why this shape: Walk-graph correctness is declaration-driven, not host-implementation-driven. A host bridge that happens to support location → settlement today may not tomorrow (or in a mod / alternate host); the declaration is the contract. Two consequences follow:
- The compiler can type-check
scope.Xexpressions without consulting the host —settlement.Xinside a location-rooted method is either backed bywalks_to Settlementinscope Location(legal) or rejected at compile time (illegal). - The registry-time walk-resolution path becomes a table lookup against the declaration arrays rather than a runtime dispatch to host-bridge methods that may or may not succeed.
The alternative — discovering walk edges dynamically by trying host.WalkScope(...) and treating null as "probably fine" — is rejected. SECS0111 replaces permissive fallbacks with a compile-time guarantee. A declared walk can still return EntityHandle.Null at runtime when the relationship is absent for this specific entity (for example, a wild unowned location has no settlement); that is normal game state and the generated query or method decides whether null means false, no-op, or something else.
Notes: None at the correctness level. Whether the compiler should accept multi-hop declarations implicitly (adding walks_to Area on scope Location auto-enables walks_to Region because region is area's declared parent) or require every reachable target to be listed explicitly is a lowering ergonomics question (see §"walks_to" open questions — design (1) vs (2)). SECS0111 fires in either design: in design (1), the transitive closure of declared walks defines the legal set; in design (2), the literal set of walks_to lines defines it.
Contract declarations
A contract is the shape of a template API — it pins a template to a root scope, declares the typed query methods and void methods that templates may implement, and records lifecycle bindings that tell the runtime which declared void methods to call automatically. Templates do not declare their own scope; they bind to a contract. Multiple templates share one contract (every Building template roots at Location, exposes the CanBuild query, and implements void methods such as OnBuilt / OnDestroyed / OnDayTick).
contract { root_scope / queries / methods / lifecycle bindings }
What: A contract declaration names a root scope, a query set, a void-method set, and optional lifecycle bindings. Queries are read-only callable surfaces with explicit return types; they cannot enqueue commands or mutate host fields. Methods are void command-producing surfaces; they receive ITemplateCommandContext, enqueue effects through context.Commands, and flush with context.FlushCommands() after command-producing source statements. Lifecycle bindings such as activation OnBuilt; select one of the declared void methods for an automatic host/runtime call. CanBuild is one ordinary query name, not a compiler keyword and not a hard-coded Building special case.
Scope-frame rule: Every contract-declared query or method executes inside a scope frame derived from the contract's root_scope. Source-level contract functions do not accept arbitrary game-world parameters such as Location, Settlement, or Owner to describe where they run. The caller supplies the root-scope entity and, for existing-instance calls, the owner entity; the compiler binds those as root and this / owner. All other world data must be reached through declared scope walks (@Settlement, @Province, @Location, etc.) or through value/helper parameters on scope methods. Creation-time queries run before a template instance exists, so their frame has root but no this or owner. Existing-instance queries and void methods add this / owner for that instance while keeping root as the root-scope target.
Callable API rule: Every contract-declared query and every contract-declared void method is callable from both host C# and SECS source through the same generic contract-call API. The host supplies template id, query/method id, a ScopeFrame, and either an ISecsHostReads read boundary for queries or an ITemplateCommandContext command boundary for void methods. SECS source calls the same surface by naming the template, the query/method, and an explicit context(...) frame. Lifecycle bindings are just automatic callers of this same method surface; they do not create a private hook path.
Context-profile rule: A contract may name reusable ordered context chains for effective template-value reads. A profile is owned by the contract because its expressions are interpreted relative to that contract's root_scope and declared walks. The profile is not a guard, payer, validation hook, or mini query language; it expands to an ordered list of context entities for template_field(Template, Field, ProfileName). Null handling, affordability checks, resource payer selection, and any "can this happen?" decisions remain ordinary code in contract query bodies such as CanBuild().
Lifecycle vocabulary rule: The engine owns the lifecycle slot identifiers; the game owns the method names. activation means "this template instance became active" in every game. OnBuilt only means "built" because Valenar's Building contract chose that name. A different game can bind the same engine slot to OnEquipped, OnCrafted, OnUnlocked, or any other declared void method:
contract Equipment
{
root_scope Character;
activation OnEquipped;
deactivation OnUnequipped;
query bool CanEquip();
method void OnEquipped();
method void OnUnequipped();
}
The runtime still sees only ContractLifecycleIds.Activation -> H.Contract_Equipment_OnEquipped_NoArgs_Void and ContractLifecycleIds.Deactivation -> H.Contract_Equipment_OnUnequipped_NoArgs_Void. No engine branch knows what "equipped" means. This is what keeps SECS a generic scripting engine rather than a colony-builder-specific engine.
SECS:
contract Building
{
root_scope Location;
activation OnBuilt;
deactivation OnDestroyed;
query bool CanBuild();
method void OnBuilt();
method void OnDestroyed();
method void OnDayTick();
context BuildCostContext
{
@Settlement;
@Location;
}
}
contract Settlement
{
root_scope Settlement;
activation OnCreated;
method void OnCreated();
method void OnDayTick();
method void OnSeasonTick();
}
// Locations are claimed and designated at runtime; OnClaimed/OnDesignated fire
// from host code when the player (or auto-claim system) acts on a location.
contract Location
{
root_scope Location;
activation OnLoaded;
method void OnLoaded();
method void OnClaimed();
method void OnDesignated();
}
contract Feature
{
root_scope Feature;
activation OnSpawned;
query FeaturePlacementResult EvaluatePlacement(
Location candidate,
FeaturePlacementInput input);
method void OnSpawned();
method void OnDiscovered();
}
Source: domain-owned contract files such as examples/valenar/Content/locations/features/contracts.secs.
Compiles to:
public static readonly ContractDeclaration[] Contracts =
[
new()
{
ContractId = H.BuildingContract,
RootScopeId = H.Location,
QueryMethodIds = [H.Contract_Building_CanBuild_NoArgs_Bool],
MethodIds =
[
H.Contract_Building_OnBuilt_NoArgs_Void,
H.Contract_Building_OnDestroyed_NoArgs_Void,
H.Contract_Building_OnDayTick_NoArgs_Void,
],
LifecycleBindings =
[
new() { BindingId = ContractLifecycleIds.Activation, MethodId = H.Contract_Building_OnBuilt_NoArgs_Void },
new() { BindingId = ContractLifecycleIds.Deactivation, MethodId = H.Contract_Building_OnDestroyed_NoArgs_Void },
],
},
new()
{
ContractId = H.SettlementContract,
RootScopeId = H.Settlement,
QueryMethodIds = [],
MethodIds =
[
H.Contract_Settlement_OnCreated_NoArgs_Void,
H.Contract_Settlement_OnDayTick_NoArgs_Void,
H.Contract_Settlement_OnSeasonTick_NoArgs_Void,
],
LifecycleBindings = [new() { BindingId = ContractLifecycleIds.Activation, MethodId = H.Contract_Settlement_OnCreated_NoArgs_Void }],
},
new()
{
ContractId = H.BuildOrderContract,
RootScopeId = H.Location,
QueryMethodIds = [],
MethodIds = [],
LifecycleBindings = [],
},
// Map hierarchy contracts — instantiated by Host/Map/MapGenerator
new() { ContractId = H.RegionContract, RootScopeId = H.Region, QueryMethodIds = [], MethodIds = [H.Contract_Region_OnLoaded_NoArgs_Void], LifecycleBindings = [new() { BindingId = ContractLifecycleIds.Activation, MethodId = H.Contract_Region_OnLoaded_NoArgs_Void }] },
new() { ContractId = H.AreaContract, RootScopeId = H.Area, QueryMethodIds = [], MethodIds = [H.Contract_Area_OnLoaded_NoArgs_Void], LifecycleBindings = [new() { BindingId = ContractLifecycleIds.Activation, MethodId = H.Contract_Area_OnLoaded_NoArgs_Void }] },
new() { ContractId = H.ProvinceContract, RootScopeId = H.Province, QueryMethodIds = [], MethodIds = [H.Contract_Province_OnLoaded_NoArgs_Void], LifecycleBindings = [new() { BindingId = ContractLifecycleIds.Activation, MethodId = H.Contract_Province_OnLoaded_NoArgs_Void }] },
new() { ContractId = H.LocationContract, RootScopeId = H.Location, QueryMethodIds = [], MethodIds = [H.Contract_Location_OnLoaded_NoArgs_Void, H.Contract_Location_OnClaimed_NoArgs_Void, H.Contract_Location_OnDesignated_NoArgs_Void], LifecycleBindings = [new() { BindingId = ContractLifecycleIds.Activation, MethodId = H.Contract_Location_OnLoaded_NoArgs_Void }] },
// Feature contract — created at generation time, discovered lazily by the player
new() { ContractId = H.FeatureContract, RootScopeId = H.Feature, QueryMethodIds = [], MethodIds = [H.Contract_Feature_OnSpawned_NoArgs_Void, H.Contract_Feature_OnDiscovered_NoArgs_Void, H.Contract_Feature_OnInteract_NoArgs_Void, H.Contract_Feature_OnCleared_NoArgs_Void], LifecycleBindings = [new() { BindingId = ContractLifecycleIds.Activation, MethodId = H.Contract_Feature_OnSpawned_NoArgs_Void }] },
// Character contract — created once by the host runtime after map init
new() { ContractId = H.CharacterContract, RootScopeId = H.Character, QueryMethodIds = [], MethodIds = [H.Contract_Character_OnSpawned_NoArgs_Void], LifecycleBindings = [new() { BindingId = ContractLifecycleIds.Activation, MethodId = H.Contract_Character_OnSpawned_NoArgs_Void }] },
];
public static readonly ContractMethodDeclaration[] ContractMethods =
[
new()
{
ContractId = H.BuildingContract,
MethodId = H.Contract_Building_CanBuild_NoArgs_Bool,
Name = "CanBuild",
ReturnType = SecsTypeRef.Bool,
ParameterTypes = [],
IsQuery = true,
},
new()
{
ContractId = H.BuildingContract,
MethodId = H.Contract_Building_OnBuilt_NoArgs_Void,
Name = "OnBuilt",
ReturnType = SecsTypeRef.Void,
ParameterTypes = [],
IsQuery = false,
},
new()
{
ContractId = H.BuildingContract,
MethodId = H.Contract_Building_OnDestroyed_NoArgs_Void,
Name = "OnDestroyed",
ReturnType = SecsTypeRef.Void,
ParameterTypes = [],
IsQuery = false,
},
new()
{
ContractId = H.FeatureContract,
MethodId = H.Contract_Feature_EvaluatePlacement_Location_FeaturePlacementInput_FeaturePlacementResult,
Name = "EvaluatePlacement",
ReturnType = SecsTypeRef.Record(H.FeaturePlacementResult),
ParameterTypes =
[
SecsTypeRef.EntityInScope(H.Location),
SecsTypeRef.Record(H.FeaturePlacementInput),
],
IsQuery = true,
},
// ... one row per declared query/method on every contract.
];
public static readonly ContextProfileDeclaration[] ContextProfiles =
[
new()
{
ContextId = H.BuildCostContext,
ContractId = H.BuildingContract,
Name = "BuildCostContext",
Entries =
[
new() { ScopeId = H.Settlement },
new() { ScopeId = H.Location },
],
},
];
Source: examples/valenar/Generated/Declarations.cs for the implemented scalar examples; the EvaluatePlacement row is the doc 07 design target. Target structs: ContractDeclaration at src/SECS.Abstractions/Contracts/ContractDeclaration.cs, conceptually shaped as { ContractId, RootScopeId, QueryMethodIds[], MethodIds[], LifecycleBindings[] }; future ContractMethodDeclaration is shaped as { ContractId, MethodId, Name, ReturnType: SecsTypeRef, ParameterTypes: SecsTypeRef[], IsQuery }; and ContextProfileDeclaration at src/SECS.Abstractions/Contexts/ContextProfileDeclaration.cs, shaped as { ContextId, ContractId, Name, Entries[] }. MethodIds[] contains void methods and each lifecycle binding maps an engine lifecycle id such as ContractLifecycleIds.Activation or ContractLifecycleIds.Deactivation to one of those method ids.
Why this shape: The contract is the pin that lets the engine route automatic lifecycle calls and expose template-specific callable surfaces without special cases. Listing queries explicitly makes read-only availability checks part of the contract API: host UI can ask CanBuild, SECS code can ask the same query inside systems/events, and mods can replace a query body without the compiler treating CanBuild as magic. Listing void methods explicitly (rather than discovering them from template bodies) lets the compiler diff template implementations against the contract — templates that implement functions not on the contract are rejected at compile time; contracts with unimplemented methods fall through to a default no-op.
ContractDeclaration is the fast runtime dispatch/lifecycle table. ContractMethodDeclaration is the compiler-facing callable signature table. The registry validates that every signature row references an already-registered contract and that query rows appear in QueryMethodIds while command rows appear in MethodIds; the metadata table does not grant dispatch by itself. This gives Roslyn enough information to bind explicit query / method template members and calls by owner + SecsTypeRef signature while keeping runtime invocation keyed by ulong ids. Queries may return declared structs/records/enums/tags/container values; methods remain command-producing and must return void.
The lifecycle table is intentionally one level of indirection: engine slot id -> developer method id. Adding a new generic engine moment later (for example a transition-enter slot) adds one lifecycle id and one binding row; it does not reserve method names like OnBuilt or OnEquipped globally. Games can keep their own vocabulary, and SECS stays responsible only for when the automatic call happens.
Lifecycle bindings are optional. A contract with no activation binding compiles and receives no automatic activation method call.
Context profiles are similarly optional and source-level. The compiler treats each named profile as contract-owned metadata and expands it at each read site: template_field(Farm, GoldCost, BuildCostContext) is equivalent to template_field(Farm, GoldCost, context(@Settlement, @Location)) inside a Building template. The ordered broad-to-narrow context-chain semantics live in 05-expressions.md § "Template value read".
Notes:
- Whether the source spelling should remain
activation OnBuilt;or switch toon_activate OnBuilt;. Both are lifecycle-binding spellings; neither changes the rule that the target must be a declared void method. - Contract inheritance / mixins (spec-level
extends) are not present in either.secssources orGenerated/. If introduced, they would need to flatten at lowering time — contracts would remain a flat array with no hierarchy.
Mod scope note (contracts are not extensible by mods): Contracts are base-only. Mods may declare new contracts but may not extend existing contracts — a mod may not add new query methods to a base-declared contract's query list, may not add new void methods to its method list, may not change root_scope, and may not change lifecycle bindings such as activation. To introduce a new lifecycle hook or query surface (e.g., OnRaided / CanRaid on Building-shaped templates), a mod declares a new contract (contract Raidable { root_scope Location; query bool CanRaid(); method void OnRaided(); }) and a new template hierarchy that binds to it. The merger emits SECS0602 if a mod declares a top-level contract block that names an already-declared contract identifier; there is no per-query or per-method additive merge for existing contracts. Cross-reference 06-overrides-and-modding.md § "What's NOT overridable".
Top-level Channel declarations
A channel is a named, typed, optionally-clamped value the engine resolves through the 6-phase target pipeline. Top-level channel declarations introduce the channel's identity and metadata — its name, its type (int / long / float / double / bool), its ChannelKind, whether it binds to a host-owned scope field via source =, and its clamps. Template bodies declare per-instance channels — see 02-templates.md. Modifier effects (Gold += 10;, FoodOutput *= 50%;, FoodCapacity = 10;) are introduced in 03-channels-and-modifiers.md. This document only covers the declaration.
Numeric type set (committed 2026-04-24)
What: A channel's compile-time type is one of exactly five values: int (32-bit signed), long (64-bit signed), float (IEEE 754 single-precision), double (IEEE 754 double-precision), bool (boolean). The same type set applies to scope fields (§"scope with fields" above) and to modifier effect payloads (see 03-channels-and-modifiers.md). The set is closed — no further numeric types are accepted in Phase 1 of the compiler bring-up or at any later phase without an explicit design change.
SECS:
channel int Population { kind = Base; source = settlement.Population; min = 0; }
channel long TotalXp { kind = Base; source = character.TotalXp; min = 0L; }
channel float Fertility { kind = Base; source = location.Fertility; min = 0.0; max = 1.0; }
channel double WorldTime { kind = Base; source = world.WorldTime; }
channel bool IsFounded { kind = Contributed; source = settlement.IsFounded; }
Compiles to: The target enum is SecsScalarType at src/SECS.Abstractions/Channels/ChannelDeclaration.cs:3-9:
public enum SecsScalarType : byte
{
Int,
Long,
Float,
Double,
Bool,
}
Each top-level channel lowers to a ChannelDeclaration row whose ValueType is one of these five values. Each scope field lowers to a ScopeFieldDeclaration whose FieldType is one of these five values.
Why this shape (per CLAUDE.md tenets): Five types from day one is the complete correct algorithm, not a minimal-and-extend approximation. The argument for minimalism ("start with int / float, add long / double when a user asks") is a shortcut the tenets reject: retrofitting 64-bit scalars after int is the sole integer type means breaking every ChannelResolver fast-path, every ISecsHostReads.ReadInt signature, every Command.IntValue union slot, and every compiler codegen template. All five types land together in the original design.
Explicitly rejected:
uint/ulong— unsigned integers overlap with clamped signed types (channel int X { min = 0; }covers the natural-number use case). Adding a second unsigned path doubles theChannelResolversurface for no gain.byte/short— narrow-width integers are a premature optimisation. Storage for aChannelDeclarationentry is dominated by theNamestring and the union slots, not the 4-byteIntpayload; shrinking to 1 or 2 bytes saves nothing at the scale SECS operates at.decimal— niche (accounting-grade fixed-point). No current or anticipated Valenar channel requires 28-digit decimal precision; when a use case emerges,decimalis a single-enum-entry addition without restructuring.BigInt— arbitrary-precision integers are a separate multi-month project (arithmetic isO(n), notO(1); the fast-path union model breaks; the tooltip / modifier / formula layers all need parallel codepaths). Out of scope for Phase 1 and Phase 2; revisit only if a shipped game needs it.
Mod scope note (closed surface): SecsScalarType is a closed enum at the engine level. Mods cannot extend it. A mod's channel <unlisted-type> Foo { ... } (e.g., channel decimal Treasury { ... } or channel uint Count { ... }) emits a binder diagnostic at the mod's compile time — proposed SECS0102 (binder rejects unsupported channel type). The same rule applies to scope-field type declarations. New numeric types require an engine + compiler release, not a mod release. Cross-reference 06-overrides-and-modding.md § "What's NOT overridable".
bool channels are valid at all three ChannelKind values (Contributed, Base, Accumulative). There is no diagnostic that restricts bool to one kind — the type system permits any kind for any type, and the semantics are well-defined in every combination. A bool channel's resolution pipeline follows the same target phase order as numeric channels, with two specialisations: (1) modifier effect modes on bool channels are restricted to HardOverride (see 03-channels-and-modifiers.md — Additive / Multiplicative are meaningless on booleans), and (2) bool channels do not accept min / max clamps (see §"Clamp literal types" below). HardOverride is the boolean's only meaningful modifier mode: a single modifier sets the value to true or false and wins. See 03-channels-and-modifiers.md §"HardOverride mode" for the full semantics.
Engine implementation note: The 5-type commitment is implemented across every engine-side surface that touches channel values. Phase 1 of the compiler emits for all five types from the first day:
SecsScalarTypeenum has five entries (src/SECS.Abstractions/Channels/ChannelDeclaration.cs:3-9).ChannelResolverexposes five fast-path methods —ResolveIntFast,ResolveLongFast,ResolveFloatFast,ResolveDoubleFast,ResolveBoolFast— one per type, sharing the target pipeline but specialised for the value kind (target:src/SECS.Engine/Resolution/ChannelResolution.cs).ISecsHostReadsandISecsHostWritescarry fiveReadX/WriteXpairs so the host bridge can expose typed field I/O without boxing (target:src/SECS.Abstractions/Interfaces/ISecsHostReads.cs,ISecsHostWrites.cs).Commandcarries five payload slots (one per type) andModifierEffectcarries the same five for effect values (target:src/SECS.Abstractions/Commands/Command.cs,src/SECS.Abstractions/Modifiers/ModifierEffect.cs).ChannelDeclarationholds four numeric pairs ofMin/Maxfields (Min,MinLong,MinFloat,MinDouble;boolhas no clamps).
The enum name is deliberately neutral: SecsScalarType is shared by channels, template fields, scope fields, command payloads, formula delegates, and modifier effects. The declaration family (channel, field, or scope field) decides semantics; the scalar enum only decides storage and arithmetic.
Array ordering: channels emit into Declarations.Channels[] following the rule in 00-overview.md § "Compiler-output ordering convention". Within a source file, declarations emit in source order; within a single source set, files are processed in alphabetical path order under the set root; cross-source-set merging then applies the layered-mod rule: legal replace operations on replaceable declaration families keep the existing H.X slot, while net-new declarations append at the current array end in the later source set's within-set order. Top-level channel declarations themselves are create-only, so a second channel declaration with the same identity is redundant or invalid rather than a legal replacement. The same rule governs ScopeFields[], Scopes[], Contracts[], and every other declaration table.
Model: intrinsic sources are own-root, modifiers cross scopes
Rule (committed 2026-04-24, clarified 2026-05-01): A top-level channel declaration defines a resolver channel. ChannelKind.Contributed starts at zero, may receive compiler-emitted template intrinsic channel sources on the template activation root, and then receives modifier effects. There is no language-level "contribution" primitive that pushes anonymous values into arbitrary scopes — cross-scope effects (a building affecting its settlement's Morale) are expressed as named modifier attachments. This is the canonical statement of the model; other docs (02, 03) reference this subsection.
The three shapes of "channel effect":
| Shape | SECS surface | Engine mechanism | Example |
|---|---|---|---|
| Host-owned base | channel int Population { kind = Base; source = settlement.Population; } | ChannelKind.Base — host field is read at resolution phase 1; modifiers apply on top | Population counter mutated by simulation events |
| Modifier | modifier HouseMoralePresence { stacking = stackable; Morale += 5; } | ModifierBindingStore — entries aggregate at resolution phase 2/3 | Buildings contributing to settlement Morale |
| Template intrinsic channel source | template Farm { channel int FoodOutput = 5; } | CommandBuffer.RegisterChannelSource / RegisterDynamicChannelSource emitted only by template activation, targeting the activation root | A Farm template providing its location-root FoodOutput |
Concrete example: a House affecting settlement.Morale. The correct shape is a modifier attachment, not a template-body contribution. The declarations are:
// channels.secs (this doc's subject)
channel int Morale { kind = Contributed; source = settlement.Morale; min = 0; max = 100; }
// modifiers.secs (declared once, attached many times)
modifier HouseMoralePresence {
stacking = stackable;
Morale += 5;
}
// House template body (forward syntax, not yet in source file)
template<Building> House {
OnBuilt() { @Settlement.add_modifier HouseMoralePresence; }
}
Ten Houses attach ten stackable HouseMoralePresence modifiers to the settlement entity. Each binding is owned by the House that created it and targeted at the settlement. The settlement's Morale resolves from the Contributed zero base plus the ten stackable +5 modifiers and any other active modifiers, then clamps to [0, 100]. Tooltip attribution comes directly from the named modifier; destroying one House removes that House's owned binding through the owner/target lifecycle cleanup.
Why this shape:
- One cross-scope mechanism. A prior draft had templates "contributing" a value to a higher-scope channel and modifiers applying on top. Two mechanisms for "source affects another scope" was a language smell; cross-scope effects now go through modifiers.
- Targeted mod attribution requires named effects. Modifiers have stable names; an anonymous "contribution" from a template body does not. Mods that disable a specific buff need something to target.
- Tooltip attribution works naturally. "Morale: 65 = 50 base + 15 Celebration + 10 Temple" requires each source to have a display name. Modifiers provide this; raw template aggregation does not.
- The Paradox idiom. Stackable modifiers are the well-trodden path for building → province / county → realm aggregation in CK3 / EU4 / Victoria 3. SECS follows that pattern by design.
Runtime primitive note: CommandBuffer.RegisterChannelSource / RegisterDynamicChannelSource exist as internal lowering/runtime primitives for template-owned intrinsic channel sources. They are emitted by compiler-generated Activate code, must target the template activation root, and are not surfaced in .secs as a generic contribute verb. A cross-scope effect must lower as a named modifier, not as a raw channel source.
Forward pointers:
- Template-body per-instance channel semantics:
02-templates.md. - Modifier declarations and stacking policies:
03-channels-and-modifiers.md.
field declarations
What: A top-level field declaration defines a template-field identifier, source C# type, generated SecsTypeRef, display metadata, and optional clamps when the type is numeric scalar. It is the global metadata home for values assigned inside template bodies with field int GoldCost = 10; or field FeaturePlacementProfile Placement = new(...);. A field is not a channel: its base value is readonly template-definition data, read through typed template field accessors / registry APIs, and is not accepted by resolve(...).
SECS:
field int GoldCost {
name = "Gold Cost";
description = "Gold required to construct this template.";
min = 0;
}
field int WoodCost {
name = "Wood Cost";
description = "Wood required to construct this template.";
min = 0;
}
field FeaturePlacementProfile Placement {
name = "Placement";
description = "Feature placement scoring data.";
}
The declaration identifier (GoldCost) is the hash/localization-key seed. name and description are player-facing fallback text. If localization provides field_goldcost or field_goldcost_desc, those localized strings override the inline fallback; if the keys are absent or no localization provider is installed, the inline values are used.
Compiles to:
public static readonly TemplateFieldDeclaration[] TemplateFields =
[
new() { FieldId = H.GoldCost, Identifier = "GoldCost", Name = "Gold Cost", Description = "Gold required to construct this template.", ValueType = SecsTypeRef.Int, HasMin = true, Min = 0 },
new() { FieldId = H.WoodCost, Identifier = "WoodCost", Name = "Wood Cost", Description = "Wood required to construct this template.", ValueType = SecsTypeRef.Int, HasMin = true, Min = 0 },
new() { FieldId = H.Placement, Identifier = "Placement", Name = "Placement", Description = "Feature placement scoring data.", ValueType = SecsTypeRef.Record(H.FeaturePlacementProfile) },
];
TemplateFieldDeclaration mirrors ChannelDeclaration's { Identifier, Name, Description, ValueType, clamps } metadata shape for template fields, but ValueType is the general SecsTypeRef from doc 07. The generated examples currently register scalar TemplateFields[] alongside Channels[]; the design target also registers structured fields. A game with no template fields still emits an empty array so module registration stays uniform.
Base vs effective template value: Scalar fast accessors such as TemplateEntry.GetFieldInt and structured generated accessors such as Dungeon.GetPlacement() return the authored base value, and source template_field(Farm, GoldCost) / template_field(Dungeon, Placement) means base-only. TemplateValueResolver applies active modifier bindings to that base template value in a caller-supplied ordered context chain, e.g. template_field(Farm, GoldCost, context(@Settlement, @Location)) for "Farm's GoldCost in this settlement and location after contextual discounts." Additive/multiplicative runtime field effects are numeric-scalar only; whole-value HardOverride on structured fields is exact-SecsTypeRef only. A named contract profile such as BuildCostContext expands to the same ordered chain. That resolver reuses the modifier binding/effect machinery but is not the channel resolver and does not mutate the template's base field. See 03-channels-and-modifiers.md § "Template-field modifier effects", 05-expressions.md § "Template value read", and 07-structured-template-data-and-callables.md.
Why this shape: Template fields need the same author-facing tooling surface as channels — names, descriptions, clamps, hashes, localization keys — without pretending they are channel-resolved values. GoldCost is the canonical example: it is a property of the Farm template, not an entity channel on a created Farm. The host can ask "what does this template cost?" before an instance exists; the channel resolver cannot, because resolve requires an entity/scope target and a channel pipeline.
Field identifiers and channel identifiers share the global hash namespace for now. A source set that declares both field int GoldCost and channel int GoldCost is rejected to avoid ambiguous H.GoldCost, tooltip, override, and localization meanings. If the language later namespaces hashes by family, this rule can be relaxed deliberately; until then, field-vs-channel is a semantic split, not a spelling collision.
Naming decision: SecsScalarType is the channel-pipeline scalar enum. SecsTypeRef is the general type model for fields and callables. Numeric scalar fields still use the scalar members of SecsTypeRef; structured fields point at type declaration rows in Declarations.Types[].
channel with source (Contributed, Base, Accumulative)
Per design discussion 2026-04-24, clarified 2026-05-01: kind = is explicit, Contributed has no host base, template intrinsic sources are restricted to the activation root, and cross-scope aggregation uses named modifiers. See §"Model: intrinsic sources are own-root, modifiers cross scopes" above for the canonical statement; this subsection covers the declaration-surface mechanics.
What: A channel declaration takes a C# type (one of int, long, float, double, bool — see §"Numeric type set" above), an identifier, a required kind = {Contributed | Base | Accumulative} clause, optional name / description display metadata, and optional clamps. The presence of source = <scope>.<field> binds the channel to a host-owned scope field so the engine can write back / read through. kind = is required as the first sub-option; the compiler rejects a channel declaration without it. This is the committed syntax per design discussion 2026-04-24 — the "infer kind from declaration shape" approach was rejected; kind is part of the declaration, not something to infer.
SECS:
// Settlement channels — aggregated from owned locations and buildings
channel int Gold {
kind = Accumulative;
name = "Gold";
description = "Coin and precious metal stockpiled by the settlement.";
source = settlement.Gold;
min = 0;
}
channel int Morale {
kind = Contributed;
name = "Morale";
description = "The spirit and resolve of the people.";
source = settlement.Morale;
min = 0;
max = 100;
}
channel int Population {
kind = Base;
name = "Population";
description = "The number of people sheltered by the settlement.";
source = settlement.Population;
min = 0;
}
channel int FoodOutput { kind = Contributed; source = location.FoodOutput; }
Source: examples/valenar/Content/settlement/channels.secs:4-52 (abbreviated).
The declaration identifier (Gold) is the hash/localization-key seed. name and description are player-facing fallback text. If localization provides channel_gold or channel_gold_desc, those localized strings override the inline fallback; if the keys are absent or no localization provider is installed, the inline values are used.
Compiles to:
public static readonly ChannelDeclaration[] Channels =
[
// --- Accumulative: pure stockpiles, host is SOT, no modifiers ---
new() { ChannelId = H.Food_Channel, Identifier = "Food", Name = "Food", Description = "Stores of grain, meat, and foraged provisions.", Kind = ChannelKind.Accumulative, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Food_Channel },
new() { ChannelId = H.Wood, Identifier = "Wood", Name = "Wood", Description = "Timber harvested from Valenar's forests.", Kind = ChannelKind.Accumulative, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Wood },
new() { ChannelId = H.Metal, Identifier = "Metal", Name = "Metal", Description = "Iron and copper used for tools, weapons, and heavy construction.", Kind = ChannelKind.Accumulative, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Metal },
new() { ChannelId = H.Stone, Identifier = "Stone", Name = "Stone", Description = "Quarried rock used for walls, towers, and durable buildings.", Kind = ChannelKind.Accumulative, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Stone },
new() { ChannelId = H.Gold, Identifier = "Gold", Name = "Gold", Description = "Coin and precious metal stockpiled by the settlement.", Kind = ChannelKind.Accumulative, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Gold },
// --- Base: host is SOT, modifiers CAN apply ---
new() { ChannelId = H.Population, Identifier = "Population", Name = "Population", Description = "The number of people sheltered by the settlement.", Kind = ChannelKind.Base, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Population, HasMin = true, Min = 0 },
// --- Contributed: no host base (starts at 0), modifiers aggregate, SyncDirty writes ---
new() { ChannelId = H.Production, Identifier = "Production", Name = "Production", Description = "The settlement's combined construction and workshop output.", Kind = ChannelKind.Contributed, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Production },
new() { ChannelId = H.Morale, Identifier = "Morale", Name = "Morale", Description = "The spirit and resolve of the people.", Kind = ChannelKind.Contributed, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Morale, HasMin = true, Min = 0, HasMax = true, Max = 100 },
new() { ChannelId = H.PopulationCap, Identifier = "PopulationCap", Name = "Population Capacity", Description = "The maximum number of people the settlement can house.", Kind = ChannelKind.Contributed, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.PopulationCap, HasMin = true, Min = 0 },
new() { ChannelId = H.Garrison, Identifier = "Garrison", Name = "Garrison", Description = "Armed defenders stationed throughout the settlement.", Kind = ChannelKind.Contributed, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Garrison, HasMin = true, Min = 0 },
new() { ChannelId = H.Defense, Identifier = "Defense", Name = "Defense", Description = "The settlement's fortification strength.", Kind = ChannelKind.Contributed, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Defense, HasMin = true, Min = 0 },
];
Source: examples/valenar/Generated/Declarations.cs:78-120. Target struct: ChannelDeclaration at src/SECS.Abstractions/Channels/ChannelDeclaration.cs:39-76, whose scalar metadata fields are { ValueType, HasMin, Min, MinLong, MinFloat, MinDouble, HasMax, Max, MaxLong, MaxFloat, MaxDouble }. The compiler populates the slot matching ValueType; bool channels do not populate clamp slots. See §"Numeric type set" and §"Clamp literal types".
Why this shape: The engine can't resolve a channel until it knows (a) its type, for arithmetic; (b) where the phase-1 value comes from, for host base vs zero plus intrinsic sources; (c) whether modifiers apply, for phases 2-4; (d) whether to write back, for SyncDirty. ChannelKind encodes those behavioural bits with a single enum. Clamps at declaration time apply after HardOverride and are enforced per channel regardless of whether the phase-1 value comes from a host field, template intrinsic channel sources, or zero. Under the committed model (see §"Model: intrinsic sources are own-root, modifiers cross scopes" above), there is no generic summed-contribution language surface — cross-scope aggregation happens through modifiers.
Notes:
- Whether
kind =should also accept lowercase (kind = base) to reduce visual noise, or stay capitalised to mirror theChannelKind.BaseC# enum one-for-one. Current commitment: capitalised, for cross-language identity.
The "kind inference" line of questioning (source-less defaults, shape-based defaults, sum-of-contribution semantics) is closed as of 2026-04-24: the committed model makes kind part of the declaration, not something to infer, and removes contribution-summing as a language mechanism.
ChannelKind.Contributed
What: The channel has no host field base — the phase-1 value starts at 0, then adds any compiler-emitted intrinsic channel sources registered on that target by template activation. Active modifier effects apply after that and the declaration clamps the result. SyncDirty writes the resolved value back to the bound scope field so the host (UI, other systems) can read it. The source = ... binding is a write-back cache target, not a base-value source. Under the committed model (see §"Model: intrinsic sources are own-root, modifiers cross scopes"), this is the shape for channels whose value is engine-resolved rather than host-owned.
SECS (declaration only):
channel int Morale { kind = Contributed; source = settlement.Morale; min = 0; max = 100; }
channel int PopulationCap { kind = Contributed; source = settlement.PopulationCap; min = 0; }
channel int Defense { kind = Contributed; source = settlement.Defense; min = 0; }
Source: examples/valenar/Content/settlement/channels.secs:59-110 (abbreviated).
Compiles to:
new() { ChannelId = H.Morale, Identifier = "Morale", Name = "Morale", Description = "The spirit and resolve of the people.", Kind = ChannelKind.Contributed, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Morale, HasMin = true, Min = 0, HasMax = true, Max = 100 },
new() { ChannelId = H.PopulationCap, Identifier = "PopulationCap", Name = "Population Capacity", Description = "The maximum number of people the settlement can house.", Kind = ChannelKind.Contributed, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.PopulationCap, HasMin = true, Min = 0 },
new() { ChannelId = H.Defense, Identifier = "Defense", Name = "Defense", Description = "The settlement's fortification strength.", Kind = ChannelKind.Contributed, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Defense, HasMin = true, Min = 0 },
Source: examples/valenar/Generated/Declarations.cs:92-95.
Why this shape: Contributed exists because some channels have no natural host base. Take location.FoodOutput — a location does not own a standalone FoodOutput value in its own right; the value is provided by the active building template's intrinsic channel source and any modifiers. Similarly, settlement.Morale starts at zero and is built up from Houses, Temples, celebration modifiers, etc. through named modifiers. The source = binding gives the host a readable field (populated by SyncDirty) for UI and other systems, but no host code ever writes into it — the engine is authoritative.
Notes: None — resolved by the committed explicit kind = syntax and the own-root channel-source contract. The historical ambiguity (inferring Contributed vs Base from declaration shape, or "sum of template contributions" as a generic language mechanism) is closed; the author states the distinction directly, template-body channel sources are intrinsic to their activation root, and cross-scope aggregation is handled by modifiers.
ChannelKind.Base
What: The channel's base value is the host's scope field; modifiers apply on top at resolution time; SyncDirty does not write back (to avoid compounding the modifier effect every tick). Use for channels where the host is authoritative and modifiers are additive/multiplicative decorations on the host value.
SECS (declaration only):
channel int Population { kind = Base; source = settlement.Population; min = 0; }
Source: examples/valenar/Content/settlement/channels.secs:68-75.
Compiles to:
// --- Base: host is SOT, modifiers CAN apply ---
new() { ChannelId = H.Population, Identifier = "Population", Name = "Population", Description = "The number of people sheltered by the settlement.", Kind = ChannelKind.Base, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Population, HasMin = true, Min = 0 },
Source: examples/valenar/Generated/Declarations.cs:87-88.
Why this shape: Population is a game-logic counter the host mutates through simulation events (births, deaths, immigration) — it has a natural host base that the engine does not own. But modifiers like a plague or a population-boost event should still register against it at resolution time. Base allows that without the write-back feedback loop.
Notes: None — resolved by the committed explicit kind = syntax. The "infer from whether any modifier targets the channel" alternative was rejected because it makes a channel's semantics depend on unrelated files, and removing the last modifier targeting it would silently flip the kind.
ChannelKind.Accumulative
What: The channel is a pure host-owned stockpile. Modifiers do not apply (modifiers should target rates, not reservoirs). SyncDirty does not write back. Use for resource stockpiles — gold, food, wood — where a ProductionRate * delta accumulates into the stockpile each tick and modifiers belong on the rate channels, not the pool.
SECS (declaration only):
channel int Gold { kind = Accumulative; source = settlement.Gold; min = 0; }
channel int Food { kind = Accumulative; source = settlement.Food; }
channel int Wood { kind = Accumulative; source = settlement.Wood; }
channel int Metal { kind = Accumulative; source = settlement.Metal; }
channel int Stone { kind = Accumulative; source = settlement.Stone; }
Source: examples/valenar/Content/settlement/channels.secs:7-52 (abbreviated).
Compiles to:
// --- Accumulative: pure stockpiles, host is SOT, no modifiers ---
new() { ChannelId = H.Food_Channel, Identifier = "Food", Name = "Food", Description = "Stores of grain, meat, and foraged provisions.", Kind = ChannelKind.Accumulative, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Food_Channel },
new() { ChannelId = H.Wood, Identifier = "Wood", Name = "Wood", Description = "Timber harvested from Valenar's forests.", Kind = ChannelKind.Accumulative, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Wood },
new() { ChannelId = H.Metal, Identifier = "Metal", Name = "Metal", Description = "Iron and copper used for tools, weapons, and heavy construction.", Kind = ChannelKind.Accumulative, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Metal },
new() { ChannelId = H.Stone, Identifier = "Stone", Name = "Stone", Description = "Quarried rock used for walls, towers, and durable buildings.", Kind = ChannelKind.Accumulative, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Stone },
new() { ChannelId = H.Gold, Identifier = "Gold", Name = "Gold", Description = "Coin and precious metal stockpiled by the settlement.", Kind = ChannelKind.Accumulative, ValueType = SecsScalarType.Int, HasSource = true, SourceScopeId = H.Settlement, SourceFieldId = H.Gold },
Source: examples/valenar/Generated/Declarations.cs:80-85.
Why this shape: If a "Famine" modifier could halve Food, a player's stockpile would evaporate each tick the modifier is active. The designer intent is to halve the food production rate (a different channel) and let the stockpile drift. Accumulative enforces that distinction at the type level — the engine never applies modifiers even if a declaration mistakenly targets a stockpile.
Notes: None — resolved by the committed explicit kind = syntax. The "naming-convention" and "downstream-usage" alternatives were rejected: both are silent-flip hazards (rename a channel or remove the last modifier targeting it, and the kind changes invisibly).
Resource amount, capacity, and flow
What: A resource system is three different channels, not one overloaded channel:
| Channel | SECS shape | Who owns it | Modifiers? | Example |
|---|---|---|---|---|
| Amount / stockpile | kind = Accumulative channel backed by a mutable scope field | Host/game state | No | Food, Gold, Wood |
| Flow / rate | kind = Contributed or kind = Base channel | Engine if aggregate, host if base | Yes | FoodOutput, FoodConsumption, GoldIncome |
| Capacity / storage limit | Separate kind = Contributed or kind = Base channel | Engine if aggregate, host if base | Yes | FoodCapacity, GoldCapacity |
The amount is the player's current stockpile. It changes because systems/events increment or spend it. The capacity is a resolved limit used by those systems when deciding how much of a write is allowed. A Storehouse does not modify Food; it attaches a named modifier to FoodCapacity. A famine does not halve Food; it modifies FoodOutput or FoodConsumption. This keeps "how much I have" separate from "how fast it changes" and "how much I can hold".
SECS:
scope Settlement
{
int Food; // current amount
int FoodCapacity; // cached resolved capacity for UI/host reads
int FoodOutput; // cached resolved production rate
}
channel int Food {
kind = Accumulative;
name = "Food";
description = "Stores of grain, meat, and foraged provisions.";
source = settlement.Food;
min = 0;
}
channel int FoodCapacity {
kind = Contributed;
name = "Food Capacity";
description = "The maximum food this settlement can store.";
source = settlement.FoodCapacity;
min = 0;
}
channel int FoodOutput {
kind = Contributed;
name = "Food Production";
description = "Food produced each day.";
source = settlement.FoodOutput;
min = 0;
}
modifier StorehouseFoodCapacity
{
translation = "Storehouse";
stacking = stackable;
FoodCapacity += 100;
}
modifier Famine
{
translation = "Famine";
tags = Debuff;
FoodOutput *= 50%;
}
This snippet shows a settlement-level aggregate resource model. A game may keep rates on another scope instead — Valenar currently keeps FoodOutput on location and rolls it into settlement stockpiles in a system — but the split is the same: amount, rate, and capacity stay separate.
System-side rule: Resource writers clamp against capacity at the point they write the amount. With the existing command surface that means calculating a safe delta before IncrementScopeField:
var current = ctx.Host.ReadInt(settlement, H.Settlement, H.Food_Channel);
var capacity = ctx.ChannelResolver.ResolveIntFast(settlement, H.FoodCapacity);
var accepted = Math.Max(0, Math.Min(producedFood, capacity - current));
ctx.Commands.IncrementScopeField(settlement, H.Settlement, H.Food_Channel, accepted);
Spending uses the same principle in the other direction: reject the action or clamp the negative delta so the stockpile never drops below the declared minimum. Host code may enforce its own invariants as a final safety net, but game logic should still ask the resolved capacity channel when deciding whether production can be stored.
Why capacity is a channel, not a field: A top-level field is template-definition data such as GoldCost; it can be read before an instance exists. Resource capacity is instance/game-state data: it depends on the settlement, its buildings, laws, ownership, difficulty, temporary effects, and mods. That makes it a runtime channel. If a particular game has a fixed host-authored storage limit, declare capacity as kind = Base; if capacity is the pile of Storehouses, laws, and modifiers, declare it as kind = Contributed.
Notes: None for the generic model. Individual games still decide which resource families need capacity channels, which capacity names they expose, and whether their production system discards overflow, stores overflow elsewhere, or queues it as a warning/event.
Channel with no source
What: A channel declaration without a source = clause is valid only for kind = Contributed (per SECS0105 / SECS0106 above). It declares a runtime channel with no host field binding — a value that can still be resolved on an entity and affected by modifiers, but has no scope field for host read/write-back. This is not the declaration primitive for costs, durations, radii, or other template-definition data; those use the top-level field declaration above and template-body field int X = N.
SECS:
// Runtime feature channel with no host-backed storage field.
channel int ThreatLevel {
kind = Contributed;
name = "Threat Level";
description = "How dangerous this feature is when resolved at runtime.";
min = 0;
}
channel int LootValue {
kind = Contributed;
name = "Loot Value";
description = "The reward value available on this feature.";
min = 0;
}
Valenar's current feature content uses this source-less contributed-channel shape for feature-only values such as ThreatLevel, LootValue, ResearchBonus, MoraleBonus, MineralBonus, and DefenseBonus.
Compiles to:
new() { ChannelId = H.ThreatLevel, Identifier = "ThreatLevel", Name = "Threat Level", Description = "How dangerous this feature is when resolved at runtime.", Kind = ChannelKind.Contributed, ValueType = SecsScalarType.Int, HasMin = true, Min = 0 },
new() { ChannelId = H.LootValue, Identifier = "LootValue", Name = "Loot Value", Description = "The reward value available on this feature.", Kind = ChannelKind.Contributed, ValueType = SecsScalarType.Int, HasMin = true, Min = 0 },
Note: HasSource is false (no source = clause); Kind is ChannelKind.Contributed; HasMin = true carries the min = 0 clamp. The compiler also emits Identifier separately from Name so localization keys remain stable even when the display name contains spaces.
Why this shape: A source-less channel is still a channel because the author intends to call resolve(ThreatLevel), let modifiers affect it, clamp it in the channel pipeline, and show it in channel-aware tooltips. It still lives in the global Channels array so name/hash/type are centralised, but no pipeline write-back happens. Template-definition data does not meet that bar: GoldCost must be available before an entity exists, so it belongs to the field system.
Internal flag: ChannelDeclaration.Internal is a struct field (at src/SECS.Abstractions/Channels/ChannelDeclaration.cs:68-69) reserved for compiler-synthesized channels. User-declared .secs channels always have Internal = false; internal = true syntax in .secs is deferred until a concrete shipping-game use case emerges. Compiler-emitted (synthesized) channels may set Internal = true at the compiler's discretion — the field exists precisely so the compiler can distinguish author-facing channels from type-discriminator hashes and other mechanical artefacts that must not appear in tooltips. The extension path is purely additive: adding internal = keyword syntax later is non-breaking because the struct slot is already present.
Mod scope note (Internal is base-only): All user-declared channels — base or mod — lower with Internal = false. The Internal slot is reserved for compiler-synthesised channels and is not reachable via .secs surface in either source set. A mod's compiler runs the same lowering pass as base; if the mod's emitted output sets Internal = true on a user channel, it is a mod-toolchain bug rather than a language feature.
Mod scope note (kind = requirement applies cross-source-set): SECS0101 fires on any top-level channel declaration without kind =, regardless of which source set authored it (base or any mod). There is no source-set-conditional relaxation. Channel declarations themselves are not replaceable — a mod's attempt to write replace channel int Population { kind = Base; } is SECS0602 (the mod is treating a channel declaration as a slot, but channels are create-only at the language surface). Cross-reference 06-overrides-and-modding.md § "What's NOT overridable". To redirect a base channel's source or clamps, a mod declares its own channel under a fresh name and arranges the host bridge accordingly.
Notes:
- (Closed 2026-04-24)
min/maxclamps lower uniformly regardless of whether the channel has asource =. The compiler emitsHasMin = true, Min = NandHasMax = true, Max = Mfor every declared clamp. Clamps are value constraints applied in pipeline phase 4 (Clamp), independent of source. - The "no default kind" question is closed:
kind =is required on every top-level channel, source-less or sourced. No silent Contributed fallback. - Source-less
Contributedchannels are scope-agnostic channel identities: they can be resolved on any entity target, have no host cache field, and still receive modifiers.SECS0201scope checks apply when a channel hassource = <scope>.<field>; for source-less channels, template-body intrinsic sources are kept legal by the runtime/compiler rule thatRegisterChannelSourcetargets only the activation root. - An
InternalChannelKind enum value (separate from the existingInternalboolean field onChannelDeclaration) was considered as a fourth ChannelKind for compiler-internal channels. Under E2 (committed 2026-04-24), the boolean field on the existing struct is sufficient for any present need; a separate ChannelKind value is not added. The boolean field's user-facinginternal = truesyntax is itself deferred — see the Internal flag paragraph above for the current rule.
Clamp literal types (committed 2026-04-24)
What: min = and max = clauses accept a single literal whose compile-time type must exactly match the channel's declared type. A mismatch is a compile error (SECS0110). bool channels may not declare min or max at all — booleans have no ordering, so clamps are meaningless and the compiler rejects them outright.
SECS (each channel shown with its accepted literal forms):
channel int Gold { kind = Accumulative; source = settlement.Gold; min = 0; max = 1_000_000; }
channel long TotalXp { kind = Base; source = character.TotalXp; min = 0L; max = 9_000_000_000L; }
channel float Fertility { kind = Base; source = location.Fertility; min = 0.0f; max = 1.0f; }
channel double WorldTime { kind = Base; source = world.WorldTime; min = 0.0; max = 1.0e9; }
channel bool IsFounded { kind = Contributed; source = settlement.IsFounded; } // NO min / max allowed
Compiles to: The compiler picks the matching ChannelDeclaration clamp slot based on ValueType:
// int channel — clamp lowered to HasMin / Min (int) pair
new() { ChannelId = H.Gold, ValueType = SecsScalarType.Int, HasMin = true, Min = 0, HasMax = true, Max = 1_000_000, ... },
// long channel — clamp lowered to HasMin / MinLong pair
new() { ChannelId = H.TotalXp, ValueType = SecsScalarType.Long, HasMin = true, MinLong = 0L, HasMax = true, MaxLong = 9_000_000_000L, ... },
// float channel — clamp lowered to HasMin / MinFloat pair
new() { ChannelId = H.Fertility, ValueType = SecsScalarType.Float, HasMin = true, MinFloat = 0.0f, HasMax = true, MaxFloat = 1.0f, ... },
// double channel — clamp lowered to HasMin / MinDouble pair
new() { ChannelId = H.WorldTime, ValueType = SecsScalarType.Double, HasMin = true, MinDouble = 0.0, HasMax = true, MaxDouble = 1.0e9, ... },
// bool channel — no clamp fields populated
new() { ChannelId = H.IsFounded, ValueType = SecsScalarType.Bool, HasSource = true, ... },
Target struct ChannelDeclaration at src/SECS.Abstractions/Channels/ChannelDeclaration.cs:39-76 carries one clamp pair per numeric scalar: Min/Max, MinLong/MaxLong, MinFloat/MaxFloat, and MinDouble/MaxDouble. bool deliberately has no clamp pair.
Diagnostic — SECS0110:
SECS0110: channel <name>: clamp literal type does not match channel type.
channel <int|long|float|double> expects <int|long|float|double> literals.
bool channels do not support min / max clamps.
Trigger examples:
channel int Gold { min = 0.0; }— float literal on int channel → error.channel float Fertility { max = 1; }— int literal on float channel → error (must write1.0for1.0; the compiler reads the literal form, not the value).channel long TotalXp { min = 0; }— int literal on long channel → error (must write0L).channel bool IsFounded { min = false; }— bool channel with any clamp → error.
Why this shape: Literal-type strictness at declaration forces the designer to state the channel's numeric width explicitly at every clamp site. A permissive regime (int literal on long channel implicitly widens; float literal on double channel implicitly widens) papers over declaration-type drift: if a channel's width changes from int to long, every min = 0; max = 100; clause silently keeps compiling with the int interpretation, and a 64-bit overflow case that the widening was meant to fix stays unfixed. Strict matching surfaces the cross-cutting change as a compile error the author must resolve by updating every clamp.
The bool-clamps-rejected rule is symmetric: booleans are unordered, so min / max have no semantic meaning. A permissive compiler that accepted channel bool X { min = false; } would store a clamp that never fires, which is silent-dead-code the type system should reject.
Notes: None at the type-strictness level. Literal-form conventions (whether 0 is an int literal or an untyped numeric constant that widens to long on demand) follow C#'s literal-suffix rules verbatim: 0 = int, 0L = long, 0f / 0.0f = float, 0d / 0.0 = double.
range = sugar status (deferred, 2026-04-24)
A range = 0..100 clause — sugar over min = 0; max = 100; — was considered and deferred indefinitely. The core min = / max = syntax is the committed complete form; sugar is an optional convenience, not a missing piece of architecture. No range keyword is reserved, no new diagnostic is added. If sugar is introduced later, it desugars to the same min / max pair at parse time — a purely additive, non-breaking change.
This is not a tenet violation: the core surface is the COMPLETE correct form (per CLAUDE.md — "the right amount of complexity is what the task actually requires, no speculative abstractions" is overridden here by the tenets themselves, which call for full algorithms; min / max literal-strict clamps are the full algorithm, range sugar is orthogonal ergonomics).
ChannelKind decision table
The three ChannelKind values differ on three orthogonal axes: where the base value comes from, whether modifiers apply at resolution time, and whether SyncDirty writes back to the host. The enum at src/SECS.Abstractions/Channels/ChannelDeclaration.cs:14-36 captures the combinations the engine supports:
ChannelKind | Base value source | Modifiers apply? | SyncDirty writes back? | Canonical use |
|---|---|---|---|---|
Contributed | No host field base; starts at 0, value is the modifier pile on top | Yes | Yes | Engine-authoritative channels built up purely from modifiers (morale, production, garrison, defense, location output channels). Host reads resolved value via scope field. |
Base | Host-owned scope field (host mutates directly) | Yes | No | Host-authoritative channels that accept modifier decorations (population, character level). |
Accumulative | Host-owned scope field | No | No | Host-authoritative stockpiles that modifiers must not touch (gold, food, wood, metal, stone). |
.secs → ChannelKind mapping (committed): the compiler requires explicit kind = {Contributed | Base | Accumulative} on every top-level channel declaration. There is no inference and no default.
Channel-declaration diagnostics (committed 2026-04-24):
| Code | When | Message | Cross-source-set behaviour |
|---|---|---|---|
SECS0101 | kind = clause missing | `channel declaration requires kind = {Contributed | Base |
SECS0105 | kind = Base without source = | channel kind = Base requires a source = clause. For channels with no host field, use kind = Contributed. | Fires per-set. |
SECS0106 | kind = Accumulative without source = | channel kind = Accumulative requires a source = clause. Accumulative channels are host-owned stockpiles; a source is required to identify the storage field. | Fires per-set. |
SECS0107 | Two identifiers hash-collide under case-insensitive lowering | identifier collision: '<A>' and '<B>' produce the same FNV-1a-64 hash. Rename one. | Fires across base ∪ mods union (see §"Case-collision rule" mod scope note). |
SECS0108 | Cross-assembly redeclaration of an identifier | (warning) identifier '<Name>' is already declared in assembly '<Other>'. Use the appropriate layered-mod operation if you meant to patch an existing declaration; remove the redundant declaration otherwise. | Fires when a mod redeclares a base name without an explicit mod operation. Two mods redeclaring the same name (no base) escalate to SECS0601 instead — see §"Cross-assembly same-name rule" mod scope note. |
SECS0110 | Clamp literal type does not match channel type (or bool channel has min / max) | `channel | long |
SECS0111 | WalkScope(from, ToScope) / to.X targets a scope not declared in from's walks_to list | scope walk from '<FromScope>' to '<ToScope>' is not declared. Traversable via walks_to from '<FromScope>': { <list of declared targets> }. If '<ToScope>' should be reachable, it must be declared by the base game or an enabled official expansion. | Fires across the effective host-capable scope graph: third-party mods may consume declared edges, but they do not contribute new host topology. |
Error-code convention: the four-digit code's first two digits match the doc number where the rule is specified. 01XX codes live here (doc 01 — world shape); 02XX codes live in 02-templates.md (e.g. SECS0201 for cross-scope channel in template body); 03XX in 03-channels-and-modifiers.md; etc. Current 01XX allocations: SECS0101, SECS0105, SECS0106, SECS0107, SECS0108, SECS0110, SECS0111. Codes SECS0102-SECS0104 and SECS0109 are free for future diagnostics in this topic area.
The naming-convention table below is historical — it describes the hand-authored decisions encoded in today's examples/valenar/Generated/Declarations.cs stand-in, captured here so the stand-in's intent remains legible until the bulk .secs source update lands:
- Resource stockpiles (
Gold/Food/Wood/Metal/Stone) — treated asAccumulative - Simulation counter (
Population) — treated asBase - Modifier-aggregated settlement values (
Morale,Production,PopulationCap,Garrison,Defense) — treated asContributed(no host base; value is the modifier pile)
These are the stand-in's intent captured as data. Once the compiler and channels.secs carry the explicit kind = clauses, this table becomes redundant and should be deleted.
Why Contributed exists as a distinct kind: Some channels have no natural host base — the scope does not own a meaningful value for the channel in its own right. location.FoodOutput is the archetype: a location does not hold a standalone FoodOutput number; the number comes from the active location-root template intrinsic channel source and any modifiers. settlement.Morale is the same zero-base shape, with Houses / Temples / Celebrations attaching named stackable modifiers. Contributed encodes "no host base; value starts at 0, adds own-root intrinsic channel sources and modifier effects, and may write the resolved value back so the host can display it."
Why Accumulative exists as a distinct kind: Resource stockpiles are the one place where a modifier acting on the channel would be catastrophic. A "famine" modifier that multiplies Food by 0.5 would halve the player's stockpile every single tick as long as it's active — the stockpile evaporates exponentially. The correct modelling is "famine multiplies FoodOutput (the rate) by 0.5, the rate-integration keeps feeding the stockpile at half speed, and the stockpile is never directly scaled". Accumulative enforces that at the type level by silently dropping any modifier effect that targets the channel.
Why Base exists as a distinct kind: Some channels must be host-owned (population comes from simulation events, not from aggregated modifier contributions) but still participate in the modifier system (a plague modifier should show up when resolving Population for UI display). Base is the combination "host SOT, modifiers applied on top, no write-back" — the write-back is skipped because re-writing the modifier-adjusted value back to the host field would make the next tick's resolution double-count the modifier.
Notes:
- (Closed 2026-04-24) Both
kind = Baseandkind = Accumulativenow requiresource = <scope>.<field>. Compile errors:SECS0105: channel kind = Base requires a source = clause. For channels with no host field, use kind = Contributed.SECS0106: channel kind = Accumulative requires a source = clause. Accumulative channels are host-owned stockpiles; a source is required to identify the storage field.Source-less declarations are valid only forkind = Contributed.
- (Closed) The "inference rules are fragile" question is resolved by the committed explicit
kind =syntax: misclassification is now a source-file edit, not a silent naming-heuristic flip.
The H.* hash convention
Every named identifier in a SECS world — scope names, field names, channel names, contract names, method names, scoped collection names, modifier names, template names, activities, policies, needs, selectors, rules, slot kinds — is identified at runtime by a 64-bit unsigned integer produced by FNV-1a-64 over a canonical id string. The H.* class in Generated/Hashes.cs is a static table of these constants.
Canonical id string format (Wave 5)
The canonical id string is the single source of truth for an identifier's hash. It is what TemplateId.Create, ChannelId.Create, TagId.Create, ActivityId.Create, PolicyId.Create, NeedId.Create, SelectorId.Create, RuleId.Create, and SlotKindId.Create hash; what the SECS compiler must emit; what the localization resolver looks up; and what H.<Name> constants commit to. The format is colon-slash:
<namespace>:<kind>/[<owner-or-domain>/]<name>
<namespace>is the source-set namespace.valenarfor engine-shipped Valenar content; mod-owned ids use the mod's source-set namespace (e.g.cautious_survival:rule/flee_on_low_hpfor a mod whose source set iscautious_survival).<kind>is the SECS construct kind:template,contract,scope,channel,data,modifier,system,event,on_action,activity,policy,need,selector,rule,slot,domain,tag,phase,frequency,formula,recipe. The stringchannelis used as the canonical-id kind for channel declarations even when the C# type carrying the id is stillChannelId— Wave 5 commits to the canonical string; Wave 6 will rename the C# type.<owner-or-domain>is the owning category or domain (e.g.building,character,settlement,feature,skill,resource). Omitted when the construct has no scope/owner (e.g. system ids, formulas without an owner, recipes).<name>is the declaration's snake_case name.
All four segments use snake_case. The full string is hashed verbatim with FnvHash.Compute. The hash is case-insensitive (FNV-1a-64 lowers ASCII uppercase before mixing) but the canonical string itself is snake_case so the case-insensitivity is a defensive safety property, not a relied-on normalization.
Examples:
valenar:template/building/farmvalenar:template/region/bare_regionvalenar:contract/buildingvalenar:scope/settlementvalenar:channel/character/attackvalenar:channel/settlement/foodvalenar:data/building/gold_costvalenar:modifier/house_morale_presencevalenar:system/building_productionvalenar:event/festival_spiritvalenar:on_action/building_completevalenar:activity/recruit_garrisonvalenar:policy/character_survivalvalenar:need/safetyvalenar:selector/lowest_health_allyvalenar:rule/emergency_restvalenar:slot/character_actionvalenar:tag/buffvalenar:phase/productionvalenar:formula/farm_foodvalenar:recipe/wheat_farm_recipe
Bare-name ("Farm", "HP_Regen") and dot-form ("valenar.building.farm", "valenar.channel.attack") hashes that earlier waves committed are no longer live identity targets. Deferred families (settlement scalars, on_action ids, modifier ids, system ids, event ids that still hash from bare names in Generated/Hashes.cs) carry an explicit Wave 5 follow-on slice — see the per-block deferral comments in Hashes.cs.
FNV-1a-64, case-insensitive
What: The hash function is FNV-1a-64 (offset basis 14695981039346656037 / 0xCBF29CE484222325, prime 1099511628211 / 0x100000001B3), applied byte-by-byte to each character after lowering ASCII uppercase letters ('A'..'Z' → 'a'..'z'). Non-ASCII code points pass through unchanged. The output is a ulong. The hash is identical to the function secs-roslyn will use, so a canonical id string authored in .secs source resolves to the same ulong here as in compiled code.
SECS: The hash is never written literally in .secs; identifiers are written by name and (when explicit) by canonical id string passed to a typed-id .Create() factory.
Compiles to (DESIGN TARGET — engine-side update required, see below):
public static class FnvHash
{
private const ulong FnvOffsetBasis = 0xCBF29CE484222325UL; // 14695981039346656037
private const ulong FnvPrime = 0x100000001B3UL; // 1099511628211
public static ulong Compute(ReadOnlySpan<char> text)
{
var hash = FnvOffsetBasis;
for (int i = 0; i < text.Length; i++)
{
var c = text[i];
// Lowercase ASCII for case-insensitive hashing
if (c >= 'A' && c <= 'Z')
c = (char)(c + 32);
hash ^= c;
hash *= FnvPrime;
}
return hash;
}
public static ulong Compute(string text) => Compute(text.AsSpan());
}
Implementation status (2026-04-25): src/SECS.Engine/Localization/FnvHash.cs now implements FNV-1a-64 with the constants above and returns ulong. The identifier surfaces that consume hashes (ChannelId, ScopeId, FieldId, ContractId, TemplateId, LocKey.Value, Command payload ids, registry keys, generated H.* constants, host bridge signatures, Valenar, character-trainer, and benchmarks) have been widened to ulong.
Why this shape: Mod-scale composition is the design constraint, not the Valenar-only identifier count. A shipped grand-strategy game with Steam Workshop coverage routinely runs base + ~80 mods producing on the order of 3,000 distinct identifiers in the merged registry. Birthday-paradox collision probability on this set is approximately:
| identifier count | FNV-1a-32 collision P | FNV-1a-64 collision P |
|---|---|---|
| 200 (Valenar-only) | ≈ 5 × 10⁻⁶ | ≈ 1 × 10⁻¹⁵ |
| 3,000 (base + ~80 mods) | ≈ 5 × 10⁻² (~5%) | ≈ 5 × 10⁻¹¹ |
| 100,000 (extreme mod scale) | effectively certain | ≈ 5 × 10⁻⁶ |
A 5% collision rate at mod scale is a production-blocking architectural defect — every published modpack would carry a one-in-twenty chance of a hash collision somewhere in the merged registry, and the merger would surface it as SECS0107 at build time forcing modpack authors to rename third-party identifiers they don't own. FNV-1a-64 reduces the same probability to ~10⁻¹¹, safe at any plausible mod scale. The earlier 32-bit reasoning ("readable constants, fits in single register") is wrong under mod-scale: 64-bit literals are still readable (0xC0FFEE0123456789UL) and fit in a single 64-bit register on every modern platform (x86_64, ARM64, RISC-V). Case-insensitive lowering matches the localization resolver so settlement.Gold, Settlement.gold, and settlement.gold all produce the same H.Gold — the spec treats identifiers as case-preserving at declaration and case-insensitive at lookup.
Case-collision rule (committed 2026-04-24): Because FNV-1a-64 lowers ASCII uppercase before hashing, two declarations differing only in case produce the same hash ("Gold" and "gold" both hash to H.Gold). The compiler rejects this at declaration time:
SECS0107: identifier collision: 'Gold' and 'gold' produce the same FNV-1a-64 hash under case-insensitive lowering. Rename one.
The check is implemented via a Dictionary<ulong, string> the compiler maintains while processing declarations; when a new declaration's hash is already present with a different string, the compiler emits SECS0107 and cites both source locations.
Mod scope note (SECS0107 cross-source-set): The check fires across the union of base and every loaded mod's declarations. Two mods whose declarations collide under case-insensitive lowering both error; the base may not shadow a mod's declaration via case either. The merger holds the union dictionary; both colliding source sets receive the diagnostic and the merger aborts.
Cross-assembly same-name rule (committed 2026-04-24): Two assemblies (base game + mod, or two mods) that declare a channel / modifier / template / etc. with the same name share one FNV-1a-64 hash and therefore one runtime identity. This is intentional — mods reference base-game identifiers naturally without qualification. Redundant redeclaration emits a warning rather than an error, because the layered-mod operation system (Phase 3 of SECS-Compiler-Plan.md) is the correct path for intentional patching:
SECS0108 (warning): identifier 'Gold' is already declared in assembly 'Valenar.Content' at channels.secs:3. If you intended to reuse the existing declaration, this statement is redundant and can be removed. If you intended to patch an overridable declaration, use the appropriate explicit mod operation instead of a plain redeclaration.
Mod scope note (SECS0108 three cases): The cross-assembly case splits by source-set provenance:
- Mod declares a name already in base, no explicit mod operation →
SECS0108warning. The mod's redeclaration is treated as redundant; intentional patching requires an explicit layered-mod operation. - Mod declares a name already in another mod, no explicit mod operation →
SECS0601(merger conflict report entry — see06-overrides-and-modding.md § "Conflict detection"). The two mods must coordinate or one must use the appropriate explicit mod operation against the earlier-loaded declaration.SECS0108does not fire because there is no clearly-prior base declaration to reuse. - Mod uses an explicit mod operation on a name in another mod (not base) → well-defined; last mod in load order wins by the standard load-order resolution. Conflict-report entry produced for transparency.
A two-mod identical declaration without an explicit mod operation is therefore not SECS0108 — it is SECS0601 because the merger has no canonical "earlier" target to flag as the redundant baseline.
Notes:
- (Closed 2026-04-24, Finding 12 of mod-coverage audit; implemented 2026-04-25) Hash collisions between semantically distinct names. The 32-bit width's ~5% collision probability at base + ~80-mod scale (3,000 identifiers) was a production-blocking defect; FNV-1a-64 reduces the same probability to ~10⁻¹¹.
- The
H.*class usesulongconstants.FnvHash.Computereturnsulong. All theChannelId/ScopeId/FieldIdfields on the Declaration structs areulong. Consistent.
H.* table
What: The compiler emits one public const ulong <Name> = 0x<hex>UL; line per identifier it encounters. The file groups entries by subsystem (scopes, scope fields, contracts, method names, modifiers, templates, systems, …) but the grouping is cosmetic — all constants live on one class, Valenar.Generated.H.
SECS: not written literally.
Compiles to:
public static class H
{
// Entity types / scopes
public const ulong Settlement = 0xFDDEA81D4D0E990AUL;
public const ulong Building = 0xFAE140BA7FDA6475UL;
public const ulong BuildOrder = 0xF06B81541046E5E5UL;
// Map hierarchy scopes (Region > Area > Province > Location)
public const ulong Region = 0xC755A623F50A24DDUL;
public const ulong Area = 0x89502E843EC2B8C4UL;
public const ulong Province = 0x9DFA458C4A0F0E85UL;
public const ulong Location = 0x8F90406E9A94ABE6UL;
// ...
}
Source: examples/valenar/Generated/Hashes.cs.
Why this shape: Three reasons.
- Identity across boundaries. The engine's storage (
DirtySet,ChannelCache,ModifierBindingStore,ScopeGraph) keys onulong. Host bridge signatures are(ulong entityId, ulong channelId). The constants let every layer refer to the same identity without stringly-typed lookups at hot paths. - Compile-time validation. When a modifier declares
Gold += 10;, the compiler can verify thatGoldis a known channel id. A string literal"Gold"would only fail at runtime. - Deterministic localization.
LocalizationResolverreverses hashes to display names by looking up aLocKey(wrapping the sameulong) against a YAML table. Same hashes on both sides of the bridge means zero synchronisation.
Notes:
- Two identifiers whose names differ only in ASCII case collide to the same constant. The compiler must reject the second declaration (name conflict) rather than emitting two constants with the same value. Today the Generated/ file is hand-authored so the author avoids collisions; the real compiler needs an explicit check.
- None. The hand-written
Hashes.csstand-ins use FNV-1a-64 values and do not carry hand-picked sentinels.
Where the compiler emits H.*
What: The compiler walks every .secs file in a project, collects every identifier referenced (declaration names, field names, method names, contract names, template names, system names, modifier names, formula names, scoped collection names), and emits one Hashes.cs file per output assembly with the sorted, grouped constants.
SECS: not written literally.
Compiles to: examples/valenar/Generated/Hashes.cs — one file, one class, one flat list.
Why this shape: Single file minimises cross-project drift. One class (H) gives every generated line a short prefix (H.Gold vs Valenar.Generated.Channels.Gold) that keeps declaration bodies readable. Constants rather than a Dictionary<string, ulong> means zero allocation at registry init and zero runtime hash computation for compiler-known identifiers.
Notes:
- Cross-project identifier visibility: if two assemblies each define a channel named
Gold, they share one hash only when they intentionally emit the same canonical id string. Source-set namespaces (valenar:channel/settlement/goldvsmy_mod:channel/settlement/gold) make accidental short-name reuse distinct by construction; deliberate replacement uses the mod-operation merge rules rather than a second bare-name hash. EntityHandleidentifiers are alsoulongbut they are entity instance ids (allocated at runtime), not identifier hashes. The twoulongspaces are disjoint by construction — entity ids never collide with identifier hashes because the engine allocates entity ids in a different space and the registry never compares them.
Collision handling at register time
What: When the registry registers a declaration, it inserts the { id → declaration } pair into a dictionary. If two declarations collide on the same id (different names that hash to the same value, or two declarations of the same name), the registry throws at startup.
SECS: not written literally.
Compiles to: Not shown in this doc's reference files — the collision check lives in the registry (src/SECS.Engine/SecsRegistry.cs).
Why this shape: Catching the collision at startup — before any tick runs — means a misnamed channel cannot silently corrupt another channel's resolution. Pushing the check to compile time (in the future compiler) catches it even earlier, but the registry-time check remains the last line of defence for malformed merged declaration sets or host-bug registrations.
Notes:
- When a layered mod operation intentionally replaces a declaration in a replaceable family, the registry needs to distinguish "explicit replace of existing id" from "collision between two independent declarations". The current
SecsRegistry.Registeruses "last writer wins" semantics by default. Whether the compiler should emit an explicit replacement marker to make the intent visible is open.
Registry collision policy (committed 2026-04-24): All modding goes through the compile-time merger. Runtime DLL sideload (dropping a mod DLL into a running process and expecting SecsRegistry to absorb it) is explicitly out of scope: the registry is final after SecsModule.Register returns at host startup, and any post-startup Register call on an existing id is a host-bug class (the engine may assert in debug builds; release-mode behaviour is undefined). Mod authors compose with base by feeding their .secs source through the SECS compiler alongside base content; the compiler's merger pass produces one combined SecsModule.cs per game install. Cross-reference 06-overrides-and-modding.md § "Compile-time merger model". The registry's existing "last writer wins" is preserved as a last-line-of-defence inside the engine but is not the surface mod authors target.
Lowering summary — Declarations.cs
Every structural declaration in Content/common/*.secs lands in a declaration array on Valenar.Generated.Declarations. The mapping is:
.secs construct | Source file | Lowered array | Target struct | Notes |
|---|---|---|---|---|
enum X { ... }, struct X { ... }, record X { ... } | any structural .secs file | SecsTypeDeclaration[] Types | SECS.Abstractions.Types.SecsTypeDeclaration (planned) | C# source type catalog. Generated SecsTypeRef rows point at these ids. See doc 07. |
scope <N> { walks_to <P>; ... } | scopes.secs:3-178 | ScopeDeclaration[] Scopes | SECS.Abstractions.Scopes.ScopeDeclaration | Multi-edge ParentScopeIds. Self-walks, multi-hop walks, and game-specific ownership walks all lower to rows here. |
int Foo; / long Bar; / float Baz; / double Qux; / bool Quux; inside a scope block | scopes.secs (every scope block) | ScopeFieldDeclaration[] ScopeFields | SECS.Abstractions.Scopes.ScopeFieldDeclaration | FieldType uses SecsScalarType (Int / Long / Float / Double / Bool, see §"Numeric type set"). No clamp surface on this struct. |
contract <N> { root_scope <S>; query <T> Q(); method void M(); activation M; } | contracts.secs:3-65 | ContractDeclaration[] Contracts | SECS.Abstractions.Contracts.ContractDeclaration | Fixed runtime dispatch/lifecycle shape — ContractId, RootScopeId, QueryMethodIds[], MethodIds[], LifecycleBindings[]. An omitted activation binding means no automatic activation hook. |
query <T> Q(...); / method void M(...); inside a contract block | contracts.secs:3-65 | ContractMethodDeclaration[] ContractMethods | SECS.Abstractions.Contracts.ContractMethodDeclaration | Compiler-facing callable signatures — ContractId, full-signature MethodId, Name, ReturnType: SecsTypeRef, ParameterTypes: SecsTypeRef[], IsQuery. Must match the owning contract's query/method id lists. |
field <type> <Name> { name = ...; description = ...; min = ...; max = ...; } | channels.secs or fields.secs (layout TBD) | TemplateFieldDeclaration[] TemplateFields | SECS.Abstractions.Templates.TemplateFieldDeclaration | Global metadata for template-body field assignments. ValueType is SecsTypeRef, so structured fields such as FeaturePlacementProfile Placement are legal. |
channel <type> <Name> { kind = ...; source = ...; min = ...; max = ...; } | channels.secs:4-31 | ChannelDeclaration[] Channels | SECS.Abstractions.Channels.ChannelDeclaration | Kind is stated explicitly (`kind = Contributed |
Further arrays in Declarations.cs — ModifierDeclaration[] Modifiers and template registrations — are covered in 03-channels-and-modifiers.md and 02-templates.md respectively.
The full entry point — public static class Declarations with these arrays on it — lives at examples/valenar/Generated/Declarations.cs:6-342. At host startup, SecsModule.Register walks these arrays and hands each row to the registry; once registration completes, they are immutable for the lifetime of the process.
Invariants the compiler must enforce
The .secs source and the Generated/ output are currently hand-kept in sync, but several invariants will be the compiler's responsibility once it exists. Listing them here so they can be cross-checked during compiler Phase 1+ bring-up. Each invariant carries a source-set note describing how it generalises across base, official expansions, and third-party mods. "Merged set" means the effective base/expansion declarations plus the data-only mod declarations permitted by the merger (06-overrides-and-modding.md § "The merge-pass pipeline").
- Every
SourceScopeIdreferenced in a channel declaration must correspond to an existingScopeDeclaration. A channel likechannel int Gold { source = settlement.Gold; }presupposes thatsettlementis a declared scope andGoldis a declared field on it. The compiler must emit both theScopeFieldDeclarationand theChannelDeclarationentries in lockstep, and reject sources that point at non-existent fields.- Source-set note: the check runs against the merged set. A third-party mod's host-backed channel source may reference only scopes exported by the base game or enabled official expansions. Data-only mods cannot introduce their own source scopes because that would require host storage.
- Every
SourceFieldIdreferenced in a channel declaration must correspond to an existingScopeFieldDeclarationon the named scope. Ifchannels.secsnamessettlement.Goldbutscopes.secsdoes not declareint Gold;onsettlement, the compiler must reject. Today this is true by construction — both sides were hand-authored to match — but future drift is the main risk.- Source-set note: runs against the merged set. A third-party mod's channel may reference a field exported by base or an enabled official expansion; it may not depend on another third-party mod adding host-backed fields.
- Every
RootScopeIdon aContractDeclarationmust correspond to an existingScopeDeclaration. A contract likecontract Building { root_scope Location; }must not parse iflocationis not declared.- Source-set note: runs against the merged set. A third-party mod-declared contract may reference base/expansion scopes, not scopes invented by data-only mods.
- Every id value emitted to
H.*must be the FNV-1a-64 of the construct's canonical id string, or of an explicitly specified wire id string for the few runtime-owned wire-id families. Tag mirrors are the special case:H.Tag_*must equal the correspondingTagId.ValuefromTagId.Create("namespace:tag/name"). The compiler must not emit hand-picked sentinel values.- Mod scope note: the rule is global — every source set's emitted constants must be FNV-1a-64. Hand-picked sentinels are invalid in mod output for the same reason they are invalid in base output (a mod identifier whose FNV happens to equal a base sentinel would silently collide). Phase 1 of the compiler bring-up rejects sentinels uniformly.
- No two identifiers may collide on the same FNV output. The registry catches collisions at startup; the compiler should catch them at build time so the error includes file and line.
- Mod scope note: the check runs across the merged set; see §"Case-collision rule" for the cross-source-set behaviour of
SECS0107.
- Mod scope note: the check runs across the merged set; see §"Case-collision rule" for the cross-source-set behaviour of
- Channel names must be unique across the entire
Channelsarray. Scope-scoped field names can repeat (bothlocationandprovincehave anOwnerId), but top-level channel names cannot —H.Goldis one constant, one row, one resolution path.- Mod scope note: uniqueness is enforced across the merged
Channelsarray. Two source sets declaringchannel int Foo { ... }collide; channels do not supportinjectorreplace, so the second declaration is redundant or invalid rather than a legal patch (seeSECS0602and the §"Channel declarations are not overridable" note above).
- Mod scope note: uniqueness is enforced across the merged
- Template field names must be unique across the entire
TemplateFieldsarray and must not collide with channel names.field int GoldCostandchannel int GoldCostcannot coexist under the current sharedH.*namespace. The compiler rejects the second declaration instead of guessing whetherGoldCostmeans template metadata or a resolver channel.- Mod scope note: uniqueness is enforced across the merged field + channel namespace. A mod may add a new field name, but it may not redeclare a base channel as a field or a base field as a channel.
- Every source type in a template field, scope method, contract query/method, struct/record field, array element, collection generic argument, template reference, or scope entity reference must resolve to a declared type or a built-in scalar/scope/template type. Unknown structured type names are
SECS0701, not late runtime failures.- Mod scope note: type lookup runs against the merged base/expansion type catalog plus permitted mod-added type declarations. Mods may add new value types for their own new fields/templates, but they may not mutate an existing type's shape.
- Callable signatures are type-stable under replacement. A mod may replace a query or method body; it may not change parameter or return
SecsTypeRef. A query replacement forFeature.EvaluatePlacement(Location, FeaturePlacementInput):FeaturePlacementResultmust keep that exact signature.- Mod scope note: signature drift is
SECS0705on the merged AST.
- Mod scope note: signature drift is
ChannelKind.AccumulativeandChannelKind.BaseimplyHasSource = true. A stockpile or host-SOT channel without a source makes no sense — the engine needs somewhere to read the host value from.- Mod scope note: invariant applies to every declaration regardless of source set.
- Every lifecycle binding must target a method listed in
ContractDeclaration.MethodIds. Otherwise an automatic runtime call targets a void method the contract does not acknowledge.- Mod scope note: invariant applies to every declaration regardless of source set. Mods cannot extend
MethodIdson a base contract (contracts are not extensible — see §"contract declarations").
- Mod scope note: invariant applies to every declaration regardless of source set. Mods cannot extend
- Every contract-declared query id must correspond to one implemented query signature, and every contract-declared method id must correspond to one implemented
voidsignature. A template implementation may omit a declared function and receive the default (query default from the caller's policy, method no-op), but it may not implement an undeclared query or method.- Mod scope note: invariant applies to every declaration regardless of source set. Mods cannot extend
QueryMethodIdsorMethodIdson a base contract.
- Mod scope note: invariant applies to every declaration regardless of source set. Mods cannot extend
- Scoped collection field names on a scope must be unique per scope. A scope declaring two
ScopedList<Building> Buildings;fields is a grammar error.- Source-set note: uniqueness is enforced per scope across the merged host-capable set. Third-party mods cannot add
ScopedList<T>/ScopedDictionary<TKey, T>fields; see §"Host-capable extension of scopes".
- Source-set note: uniqueness is enforced per scope across the merged host-capable set. Third-party mods cannot add
Cross-references
- Template bodies as template field assignments (
field int GoldCost = 10) — readonly template-definition data emitted throughTemplateEntry.GetFieldInt/GetFieldFloat→02-templates.md - Template bodies as per-instance channel declarations (
channel int X = N/channel int X { return ...; }) — intrinsic channel sources on the activation root, not generic cross-scope contributions →02-templates.md - Modifier declarations that affect channels, stacking policies, and cross-scope attachment (the sole language-level mechanism for multi-source aggregation) →
03-channels-and-modifiers.md scope.fieldreads inside method bodies,foreachoverScopedList<T>/ScopedDictionary<TKey, T>declarations, and the scope-walk resolution rules →05-expressions.md- Typed scoped collections (
ScopedList<T>,ScopedDictionary<TKey, T>), theTemplateIdbuilt-in strong type, virtual binding propagation, aggregate channels, and prev-tick snapshots — the full replacement for the barecollectionkeyword →08-collections-and-propagation.md