02 — Templates
Model commitment (2026-04-24, clarified 2026-05-01): Template-body
channel int X = N/channel int X { ... }lowers to an intrinsic channel source on the template activation root. It is not a generic contribution verb and never pushes into another scope. Cross-scope effects use named modifiers viaadd_modifier. See01-world-shape.md § "Model: intrinsic sources are own-root, modifiers cross scopes"for the full rule.
Type-model commitment (2026-05-02): Template fields are no longer scalar-only, and contract query returns are no longer constrained to
SecsValue. Template fields and callable signatures use ordinary C# source type syntax that lowers to generatedSecsTypeRefmetadata; generated query implementations return their declared concrete C# type (bool,FeaturePlacementResult, etc.), and generated command methods use typed argument structs such asSecsNoArgs.SecsValueis reserved for command payload storage or explicitly erased dynamic/tooling adapters. See07-structured-template-data-and-callables.md.
Templates are the single unit of content in SECS. One .secs file under examples/valenar/Content/<bucket>/ lowers to one .cs file under examples/valenar/Generated/Templates/<Bucket>/, and the output registers exactly one TemplateEntry against the engine's SecsRegistry. Every named thing the host can create — a Farm, a House, a Dungeon, the main character, a bare Province placeholder — is a template, even the ones with no designer-authored source. This document is lowering-first: each feature block shows the SECS surface syntax, the C# the SECS compiler must emit, and the engine behaviour the emission has to satisfy. The semantic "why" (what a channel source means in the channel pipeline, for example) is covered in the later docs and is deliberately kept out of here so the shape of the output is easy to compare against.
Prerequisites
00-overview.md— the one-file-in / one-file-out contract, hash id convention,SecsModule.Initialize(registry)01-world-shape.md—scope,contract,channel,field, and type declarations. Templates bind to a scope via their contract'sroot_scope; a template withtemplate<Building>only makes sense becausecontract Building { root_scope Location; ... }has already been declared.07-structured-template-data-and-callables.md— C# source type binding, generatedSecsTypeRefmetadata, structured template data, and typed query returns.
The engine side of the contract:
src/SECS.Engine/Instances/TemplateEntry.cs— the data class every generated template file constructs and registers. IncludesTags: ReadOnlyMemory<ulong>(set-membership classification),StructuredFields, query/method dictionaries, andActivate. See08-collections-and-propagation.mdfor the tag surface.src/SECS.Engine/Instances/TemplateActivator.cs— activates templates and dispatches contract-declared query/method delegates.registry_onlyactivation skipping is still a future engine/compiler gap; current stand-ins rely on no-op activation plus host/content convention.src/SECS.Abstractions/Contracts/ContractDeclaration.cs— future home ofIsRegistryOnly: boolfor contracts that suppress entity activation entirely. Pending engine addition.
template<Contract> Name { } — the shell
What: The outer declaration of every template. Names the template, binds it to a contract (which pins its scope), and opens a body that holds everything else.
SECS:
template<Building> Farm
{
channel int FoodOutput
{
return (int)(5 * @Location.Fertility / 33);
}
field int GoldCost = 10;
field int WoodCost = 15;
query bool CanBuild()
{
return @Settlement.Stage >= 1
&& @Settlement.Gold >= template_field(Farm, GoldCost, BuildCostContext)
&& @Settlement.Wood >= template_field(Farm, WoodCost, BuildCostContext)
&& @Settlement.Stone >= template_field(Farm, StoneCost, BuildCostContext)
&& @Settlement.Metal >= template_field(Farm, MetalCost, BuildCostContext);
}
}
(examples/valenar/Content/buildings/district/farm.secs)
Compiles to:
// Source: Content/buildings/district/farm.secs
namespace Valenar.Generated.Templates.Buildings;
public static class Farm
{
public const ulong Id = H.Farm;
public static readonly TemplateEntry Entry = new()
{
TemplateId = Id,
Name = "Farm",
ContractId = H.BuildingContract,
RootScopeId = H.Location,
Activate = Activate,
Queries = new Dictionary<ulong, ITemplateQuery>
{
[H.Contract_Building_CanBuild_NoArgs_Bool] = TemplateQuery.NoArgs(H.Contract_Building_CanBuild_NoArgs_Bool, SecsTypeRef.Bool, CanBuild),
},
};
// ... Activate and CanBuild below
}
(examples/valenar/Generated/Templates/Buildings/District/Farm.cs, lines 1-20)
Why this shape:
- The class is
public static. Templates have no per-instance C# object state — everything is data on the sharedTemplateEntry. Source-level functions receive world context through a contract scope frame; the generated static delegates receiveEntityHandlevalues only as the compiled representation of that frame. Static class matches what a future AOT-friendly compiler can emit without reflection. public const ulong Id = H.Farm;links the template's C# name to the generated FNV-1a-64 hash inGenerated/Hashes.cs. Registration uses the hash, not the class reference, so overrides and save/load are name-based.Name = "Farm"is the display name the host can use when it has a template id but no localization key — the engine stores it onTemplateEntryfor debugging and for UI fallbacks.ContractId = H.BuildingContractandRootScopeId = H.Locationare both lookups the compiler can do at emit time: the contract name came fromtemplate<Building>; the root scope came from the contract declaration'sroot_scope. Both are required init fields onTemplateEntry, so the compiler must always emit them.Activate = Activateis also required — even a template with no body (see Bare templates below) emits an emptyActivatemethod and wires it up.- Contract-declared queries are registered by id. The Farm above implements
CanBuild, andcontract Buildingdeclaresquery bool CanBuild();, so the template registers[H.Contract_Building_CanBuild_NoArgs_Bool] = CanBuild.CanBuildis contract data, not a compiler keyword. - Namespace convention:
Valenar.Generated.Templates.{Bucket}where Bucket is the subdirectory the.secsfile lives in (district_buildings→Buildings,features→Features,characters→Characters). Bare templates go underBare. The mapping is not inferred from the.secsfile contents; it comes from the file path.
Notes:
- Should multi-template
.secsfiles (likefarm.secsdeclaring Farm, IrrigatedFarm, TerracedFarm) produce one C# file per template or one C# file per.secsfile? The current hand-written output produces one C# file per template (Farm.cs, IrrigatedFarm.cs, TerracedFarm.cs). The compiler must follow suit — the file boundary is per-template, not per-source. - Should the compiler emit
[CompilerGenerated]attributes? Not currently done. Probably yes once Roslyn analyzer work starts, but tracked separately.
Template function scope frames
What: Every template function body runs inside the scope frame established by its contract. The contract's root_scope decides the frame root, the caller supplies the concrete root entity, and the compiler exposes that entity as root. Declared scope sigils (@Location, @Settlement, @Province, etc.) are walks from that frame; they are not hidden parameters. This applies to contract-declared queries, contract-declared void methods, transition eligibility queries, and template-scoped helper methods.
SECS authoring rule: Do not add game-world parameters to template functions. query bool CanBuild(Location location, Settlement settlement, Owner owner) is invalid design even if a generated C# helper could technically accept those handles. Write query bool CanBuild() and read @root, @Location, @Settlement, or other declared walks. Parameters remain valid when they are real value/helper inputs rather than world context, such as the TemplateId templateId argument on the scope method @Location.BuildingCount<Watchtower>().
Availability by function kind:
- Creation-time queries run before the template instance exists. They have
rootand declared walks fromroot; they do not havethisorowner. - Lifecycle methods (
OnBuilt,OnDestroyed,OnDayTick, etc.) run for an existing instance. They havethis/ownerfor that instance plusrootfor the contract root-scope entity. - Transition eligibility queries run for an existing instance that may change template. They have the same existing-instance frame as lifecycle methods:
this/ownername the entity being transitioned, whilerootremains the root-scope target associated with that entity.
Generated C# rule: Template delegates receive a ScopeFrame. scope.Root is the contract root_scope entity, scope.Owner / scope.This is the existing template instance when one exists, and creation-time query calls receive ScopeFrame.ForCreation(root) so scope.Owner is null. These handles are compiler/runtime plumbing for the source scope frame, not permission to thread arbitrary Location / Settlement / Owner parameters through contract APIs.
Template fields
What: A field <Type> Foo = <value>; declaration in a template body assigns a readonly value to the template definition. <Type> is written as an ordinary C# source type: numeric scalar, bool, string, enum, struct, record, array, or chosen C# collection-shaped type such as IReadOnlyList<T> / IReadOnlyDictionary<TKey,TValue>. The compiler lowers that type to generated SecsTypeRef metadata. A template field is not a channel, not a modifier effect, and not host-side scope data. The field identifier must be declared globally with a top-level field declaration in 01-world-shape.md; the template assignment supplies this template's value for that declared field.
SECS:
template<Building> Farm
{
channel int FoodOutput
{
return (int)(5 * @Location.Fertility / 33);
}
field int GoldCost = 10;
field int WoodCost = 15;
query bool CanBuild()
{
return @Settlement.Stage >= 1
&& @Settlement.Gold >= template_field(Farm, GoldCost, BuildCostContext)
&& @Settlement.Wood >= template_field(Farm, WoodCost, BuildCostContext)
&& @Settlement.Stone >= template_field(Farm, StoneCost, BuildCostContext)
&& @Settlement.Metal >= template_field(Farm, MetalCost, BuildCostContext);
}
}
template<Feature> Dungeon
{
field FeaturePlacementProfile Placement =
{
Weight = 70,
MinDistance = 8,
Traits = [FeatureTrait.Ruin, FeatureTrait.Hostile],
};
}
(examples/valenar/Content/buildings/district/farm.secs, corrected target syntax)
Compiles to:
public static readonly TemplateEntry Entry = new()
{
TemplateId = new TemplateId(Id),
Name = "Farm",
ContractId = H.BuildingContract,
RootScopeId = H.Location,
Activate = Activate,
Queries = new Dictionary<ulong, ITemplateQuery>
{
[H.Contract_Building_CanBuild_NoArgs_Bool] = TemplateQuery.NoArgs(H.Contract_Building_CanBuild_NoArgs_Bool, SecsTypeRef.Bool, CanBuild),
},
GetFieldInt = GetField,
};
public static int GetField(ulong fieldId) => fieldId switch
{
H.GoldCost => 10,
H.WoodCost => 15,
_ => 0,
};
public static readonly FeaturePlacementProfile PlacementValue = new()
{
Weight = 70,
MinDistance = 8,
Traits = SecsSets.OfTags(H.FeatureTrait, [H.Ruin, H.Hostile]),
};
public static FeaturePlacementProfile GetPlacement() => PlacementValue;
(examples/valenar/Generated/Templates/Buildings/District/Farm.cs, GetFieldInt / GetField shape)
Why this shape:
fieldgives template-definition data its own explicit surface.GoldCostcan be read before a Farm entity exists, so it cannot depend on the channel resolver's(entity, channel)target.- A scalar template field lowers to the typed
TemplateEntry.GetFieldInt/GetFieldLong/GetFieldFloat/GetFieldDouble/GetFieldBoolaccessor shape. A structured template field lowers to generated concrete accessors such asGetPlacement()plusTemplateEntry.StructuredFields[H.Placement]andTemplateFieldDeclaration.ValueType = SecsTypeRef.Record(H.FeaturePlacementProfile). Known generated call sites should receive concrete C# values; dynamic host/tooling reads validate againstSecsTypeRef. fieldvalues are readonly for the template definition. Every Farm shares baseGoldCost = 10; changing an individual created building's runtime state is a channel, scope field, modifier, or host data concern, not a template field mutation.- If context modifiers apply, they produce an effective template value through the template-value resolver described in
03-channels-and-modifiers.md; they do not change this generatedGetFieldswitch or structured accessor. A discount can make "Farm in settlement X costs 8 gold" while the Farm template still reports baseGoldCost = 10. Additive/multiplicative field modifiers are numeric-scalar only; structured fields are whole-valueHardOverrideonly, exact-type only. - Template-value reads have three committed source forms.
template_field(Farm, GoldCost)reads the static base template value only.template_field(Farm, GoldCost, context(@Settlement, @Location))reads the effective template value through an explicit ordered context chain.template_field(Farm, GoldCost, BuildCostContext)reads the effective template value through a named context profile declared by the contract. Profiles expand to context chains; they do not contain validation, payer, or affordability logic. - The top-level
fielddeclaration owns display metadata, localization (field_goldcost,field_goldcost_desc), and theSecsTypeRef. The template-body assignment owns only the per-template value and must match that exact type. - Bare scalar declarations are not exported template data.
int GoldCost = 10;at template top level is rejected; if an author wants a template field, they must writefield int GoldCost = 10;. This keeps ordinary C# local variable syntax (int x = ...) reserved for executable bodies. constis a C# helper concept, not a template-field marker. Phase 1 should reject top-levelconst int GoldCost = 10;inside a template body as exported data. If template-level private constants are allowed later, they must be explicitly private helper members and must not emitH.*, localization, template-field metadata, or mod-operation slots.
Notes:
- Whether scalar
fieldassignments should accept any C# constant expression (5 + BaseCost) or only literals in Phase 1. The generated accessor can store the folded value either way; the parser/binder rule is the only decision. Structured initializers must be compile-time immutable object literals.
Tags
What: Tags are declared as typed TagId.Create("namespace:tag/name") identity helpers in a game's Tags static class or equivalent vocabulary input. Generated output registers the collected tag catalog through SecsRegistry.RegisterTag(...), and runtime/registry validation is registry-backed. Inside a template body, tags = A, B; remains SECS slot syntax (template_tags) and emits the resolved tag hashes on TemplateEntry.Tags; template entries still store hashes for fast set-membership (template.Tags.Span.Contains(hash)).
Plain C# — tag vocabulary:
// Content/common/tags.secs (plain C# inside a .secs file)
namespace Valenar;
public static class Tags
{
public static readonly TagId Buff = TagId.Create("valenar:tag/buff");
public static readonly TagId Debuff = TagId.Create("valenar:tag/debuff");
public static readonly TagId Military = TagId.Create("valenar:tag/military");
public static readonly TagId Economic = TagId.Create("valenar:tag/economic");
}
(examples/valenar/Content/common/tags.secs)
SECS — template body usage:
template<Building> Barracks
{
tags = Military;
field int GoldCost = 30;
// ... rest of body
}
template<Building> Granary
{
tags = Economic;
field int GoldCost = 20;
// ...
}
Compiles to — vocabulary constants:
// Generated/Vocabulary/Tags.cs
namespace Valenar.Generated.Vocabulary;
public static class Tags
{
public static readonly TagId Buff = TagId.Create("valenar:tag/buff");
public static readonly TagId Debuff = TagId.Create("valenar:tag/debuff");
public static readonly TagId Military = TagId.Create("valenar:tag/military");
public static readonly TagId Economic = TagId.Create("valenar:tag/economic");
}
// Generated/Hashes.cs may mirror these as ulong constants for generated-code convenience.
public const ulong Tag_Military = 0xC13D2F4CFE37C560UL; // equals Tags.Military.Value
public const ulong Tag_Economic = 0xF538D3672704BAE6UL; // equals Tags.Economic.Value
(examples/valenar/Generated/Vocabulary/Tags.cs, with H.Tag_* mirrors in Generated/Hashes.cs)
Compiles to — template entry (one Tags init field per template that declares tags = ...):
public static readonly TemplateEntry Entry = new()
{
TemplateId = Id,
Name = "Barracks",
ContractId = H.BuildingContract,
RootScopeId = H.Location,
Activate = Activate,
Tags = new ReadOnlyMemory<ulong>(new ulong[] { H.Tag_Military }),
Queries = ...,
GetFieldInt = GetField,
};
(TemplateEntry.Tags is a live engine field; current Valenar stand-ins use this shape)
Why this shape:
- Top-level
tag <Name>;is not SECS syntax. That rejected form is not a peer totemplate,scope, orcontract. Declaring tag identifiers is ordinary C# in the game'sTagsclass. TagId.Create(canonicalString)owns tag identity. The canonical string (valenar:tag/military,game:tag/food, etc.) is what is hashed with FNV-1a-64, preventing common short names from colliding across games or mods. Any generatedH.Tag_*mirror must equal the correspondingTags.X.Value; it is a convenience constant, not a separate declaration source.tags = A, B;is a template-body slot assignment — it is not afieldassignment and not a channel source. It is static classification metadata available before any entity exists. All template types accepttags = ...uniformly (Building, Feature, Character, Province, etc.). The same assignment form is also used in modifier bodies for themodifier_tagsslot; this section only shows the template case.- Short names in
tags = A, B;resolve against the game'sTagsclass.tags = Military;andtags = Tags.Military;are equivalent when unambiguous. TemplateEntry.TagsisReadOnlyMemory<ulong>(empty when the template declares no tags). The values are typed tag identities: the game'sTagIdconstants are the vocabulary source, generatedH.Tag_*mirrors are convenience constants, and generated registration emits the collected tag catalog throughSecsRegistry.RegisterTag(...)so analyzers and runtime validation share the same registry-backed contract. Template entries store hashes for fast set-membership; they do not own tag declarations themselves.- Multiple tags are comma-separated:
tags = Buff, Military;. Single-tag and zero-tag templates are both valid. - Tags are compiler vocabulary only. They do not affect channel resolution, lifecycle, or activation order. Their primary consumers are modifier propagation filters (
propagates_to children where has_tag X) and aggregate child filters (Children.Where(has_tag X)) — see08-collections-and-propagation.md § "Part 3 (Tags)". - Undeclared tags are compile error
SECS0212: tag 'X' is not declared; add a TagId constant to the game's Tags static class or qualify an existing one. - Two tag constants whose canonical identity strings produce the same FNV-1a-64 hash are caught as a hash collision error in the same union-level collision pass used for other typed ids.
Notes:
None.
Runtime entity creation from template methods
What: create_entity <TemplateName> ... is a SECS-aware expression inside
an ordinary command-producing C# method body. It returns the created entity
handle, so it can be assigned to a local or used as a statement. In a template
method, the bare form creates the entity under the ambient root scope; a receiver
form can override the scope target. It is not a TemplateEntry metadata slot
and does not add child rows to template registration.
SECS:
template<Character> MainCharacter
{
method void OnSpawned()
{
create_entity Walking;
var surveying = create_entity Surveying with { Level = 1; XP = 45; };
}
}
Compiles to:
public static readonly TemplateEntry Entry = new()
{
TemplateId = new TemplateId(Id),
Name = "Main Character",
ContractId = H.CharacterContract,
RootScopeId = H.Character,
Activate = Activate,
Methods = new Dictionary<ulong, ITemplateCommand>
{
{ H.Contract_Character_OnSpawned_NoArgs_Void, TemplateCommand.NoArgs(H.Contract_Character_OnSpawned_NoArgs_Void, OnSpawned) },
},
};
public static void OnSpawned(ITemplateCommandContext context, in SecsNoArgs args)
{
context.CreateEntity(new TemplateId(H.Walking));
var surveying = context.CreateEntity(
new TemplateId(H.Surveying),
new Dictionary<ulong, ITemplateValue>
{
[H.Level] = TemplateData.Structured(H.Level, SecsTypeRef.Int, 1),
[H.Xp] = TemplateData.Structured(H.Xp, SecsTypeRef.Int, 45),
});
}
Why this shape:
create_entityis one generic lowered operation, usable inside existing command-producing C# bodies such as template methods, systems, events, and activity lifecycle methods. The compiler chooses the appropriate execution context (ITemplateCommandContext,TickContext, or anActivityRunContext).- Bare
create_entity T;in a template method parents tocontext.Scope.Root. Explicit receiver forms lower by passing the receiver entity as the scope target. var child = parent.create_entity T with { ... };lowers to the sameCreateEntityAPI and stores the returnedEntityHandleinchild.create_entity T with { Field = Value; };is an initializer for root-scope host fields on the created entity. It is not a nested template body.rules { ... }is intentionally not part ofcreate_entity. Rules, lifecycle methods, channels, modifiers, and other behavior live on templates so the created entity remains an instance of a statically registered template.
Notes:
- A separate declarative intrinsic-composition surface may return later, but it
should be designed deliberately after the method-body
create_entitypath is proven in Valenar.
Per-instance flat channels
What: A channel int FooBar = <literal>; declaration in a template body. This declares an intrinsic channel source on the template activation root with the given literal as its phase-1 value — it is not a contribution to some higher-scope aggregate. The channel FooBar must be declared in the owning domain's channel declaration file at a scope that matches the template's root_scope; if the declared scope differs, the compiler emits SECS0201: channel 'FooBar' declared on <other-scope> scope cannot be set on a <template-scope> template; use a modifier attached to <other-scope> instead. No method body, no formula lookup, no runtime evaluation — the value is registered during activation and is visible at resolve(FooBar) on that root target.
SECS:
template<Feature> Dungeon
{
channel int ThreatLevel = 50;
channel int LootValue = 200;
method void OnSpawned()
{
add_modifier DungeonAuraOfDread;
}
method void OnCleared()
{
add_modifier DungeonClearedReward;
}
}
(examples/valenar/Content/locations/sites/dungeon.secs, lines 9-28)
Compiles to:
public static void Activate(ScopeFrame scope, ISecsHostReads host, CommandBuffer cmds)
{
cmds.RegisterChannelSource(scope.Owner, scope.Root, H.ThreatLevel, 50);
cmds.RegisterChannelSource(scope.Owner, scope.Root, H.LootValue, 200);
}
(examples/valenar/Generated/Templates/Locations/Sites/Dungeon.cs, lines 22-26)
Why this shape:
Activateis the one emitted per-template method that runs every construct in the template body that needs runtime registration. Per-instance flat channels, per-instance derived channels, and (if any) static modifier registrations all live in this single method, ordered roughly as they appear in the .secs body.cmds.RegisterChannelSource(scope.Owner, scope.Root, channelId, flatValue)is the internal lowering/runtime primitive for installing an intrinsic channel source.owneris the template instance's entity.scope.Rootis the contract root target and must be the channel-source target; runtime validation rejects template activation code that registers a channel source anywhere else. Cross-scope effects usecmds.AddModifier.- Literal numeric value only. If the right-hand side looks like
5 + 3the compiler folds it to8at emit time; if it looks like anything involving an identifier or a scope walk, it becomes a per-instance derived channel (see next section). - Scope-match is a hard compile error. The channel's declared scope in its domain channel file must equal the template's
root_scope. If aBuildingtemplate (root_scope Location) declareschannel int Morale = 5;butMoraleis onSettlementscope, that isSECS0201. The correct expression for "this Building raises settlement Morale" is a stackable modifier attached tosettlement— see the Cross-scope effects section below. - The per-call inline
//comment you see inFarm.cs(// .secs: channel int FoodOutput { ... }) is the hand-written convention for preserving the source form. The compiler should emit the same, verbatim from the original source range. This is the template-level analogue of the/// Compiled from:docstring thatFormula_*files use.
Notes:
channel floatper-instance flat channels are not yet exercised. When they appear, the emit is the same shape with afloatvalue and (likely) a separate overload onCommandBuffer.- Clamp interaction: the base value is still subject to clamps. Clamps are declared on the channel, not the template, so the compiler has nothing to do here — clamping happens in the channel resolution pipeline.
Per-instance derived channels
What: A channel int FooBar { return <expression>; } declaration in a template body. This declares a derived intrinsic channel source on the template activation root — the value is recomputed on every resolve(FooBar) of that target by evaluating the expression body. The expression can reference other channels (via resolve(...)), scope walks (@Settlement.resolve(...)), and scope fields (@Location.Fertility). Because the expression can read world state at evaluation time, the compiler cannot fold it to a constant; it lifts the body into a separate Formula_* class in Generated/Formulas/, registers the formula against a hash, and at template activation registers the formula as this template's intrinsic source for the channel.
Same scope-match rule as per-instance flat channels: the channel must be declared in its domain channel file at a scope equal to the template's root_scope. Cross-scope sets are SECS0201: channel 'FooBar' declared on <other-scope> scope cannot be set on a <template-scope> template; use a modifier attached to <other-scope> instead. A Building template producing a settlement-scope derived channel must route through a stackable modifier attached to settlement, not through a local channel int X { return …; }.
This is the first lowering that crosses file boundaries — one .secs template produces one Templates/<Bucket>/Name.cs plus one Formulas/Formula_*.cs per per-instance derived channel. The formula body details (what the generated Evaluate method looks like, how resolve/scope.resolve lower, how dependency metadata is captured) are covered in 03-channels-and-modifiers.md. Here we only document the template-side emission.
SECS:
template<Building> Farm
{
channel int FoodOutput
{
return (int)(5 * @Location.Fertility / 33);
}
// ... other channel slots and methods
}
(examples/valenar/Content/buildings/district/farm.secs, lines 1-17)
Compiles to — template side:
public static void Activate(ScopeFrame scope, ISecsHostReads host, CommandBuffer cmds)
{
// Dynamic: FoodOutput scales with location Fertility
// .secs: channel int FoodOutput { return (int)(5 * @Location.Fertility / 33); }
cmds.RegisterDynamicChannelSource(scope.Owner, scope.Root, H.FoodOutput, H.Formula_FarmFood);
}
(examples/valenar/Generated/Templates/Buildings/District/Farm.cs, lines 22-27)
Compiles to — formula side (sibling file):
// Source: Content/buildings/district/farm.secs
namespace Valenar.Generated.Formulas;
/// <summary>
/// Farm food production scales with location Fertility (0..100 geographic value).
/// Compiled from: channel int FoodOutput { return (int)(5 * @Location.Fertility / 33); }
/// </summary>
public static class Formula_FarmFood
{
public static int Evaluate(EntityHandle target, EntityHandle owner, EntityHandle captured1, ISecsHostReads host)
{
var location = host.WalkScope(owner, H.Location);
if (location.IsNull) return 5;
var fertility = host.ReadInt(location, H.Location, H.Fertility);
return Math.Max(1, (int)(5.0 * fertility / 33));
}
}
(examples/valenar/Generated/Formulas/Formula_FarmFood.cs)
Compiles to — registration side (central SecsModule.cs):
registry.RegisterFormula(H.Formula_FarmFood, Formula_FarmFood.Evaluate,
readsChannels: []);
registry.RegisterFormulaContribution(H.Formula_FarmFood, H.FoodOutput);
(examples/valenar/Generated/SecsModule.cs, lines 35-50)
Why this shape:
- The compiler allocates a formula id (
H.Formula_FarmFood) based on the template name and channel name —Formula_{TemplateName}{ChannelName}with hash conflict detection. Two templates declaring achannel int FoodOutputon their own scopes get distinct ids (Formula_FarmFood,Formula_IrrigatedFarmFood,Formula_TerracedFarmFood), because the prefix disambiguates them. - Mod scope note. Mod-added templates follow the same
Formula_{TemplateName}{ChannelName}naming convention. A mod'stemplate<Building> MegaFarmwith a per-instance derivedchannel int FoodOutput { ... }producesFormula_MegaFarmFoodOutput. Because the prefix is the template name, mod-added formulas never collide with base formulas at the formula-id level — the SECS0602 case for two declarations sharing the same template name covers the prefix, so the formula id is unique by construction. OnceSECS0201(cross-scope template channel) is enforced, every cross-scope effect from a mod template body lowers as a modifier attachment instead, and the formula id becomesFormula_{ModifierName}{ChannelName}by extension. See06-overrides-and-modding.md § "Slot schema"for the full slot enumeration covering both base and mod-emitted formulas. - The template's
Activatelowers toRegisterDynamicChannelSource(scope.Owner, scope.Root, H.<ChannelName>, H.<FormulaId>). The engine stores this as a dynamic intrinsic source for that activation root; at resolution time it looks up the formula by id, callsEvaluate, and adds the result in theContributedphase-1 gather. The command name is intentionally runtime-facing and is not a.secsauthor verb. - The sibling formula file lives in
Valenar.Generated.Formulas.*. The compiler emits one file per formula even when multiple formulas come from the same source template — so three Farm variants each have their ownFormula_*FoodProduction.cs. SecsModule.csgrows two lines per formula:RegisterFormula(id, delegate, readsChannels: [...])andRegisterFormulaContribution(formulaId, channelId). The dependency array (readsChannels) is built from the expression'sresolvecalls and is critical for cycle detection at registration time (seeregistry.ValidateDependencies()).- The inline
// .secs: ...comment above theRegisterDynamicChannelSourcecall is the source-preservation convention; it should match the original source line verbatim (possibly reformatted, but semantically identical). - Per-instance flat and per-instance derived can co-exist inside one
Activatewhen both channels belong to the template's own root scope. The compiler emits the same two command shapes in source order. Valenar's settlement-building cases that used to demonstrate this are now modifier attachments because their target channels live onsettlement, notlocation.
Notes:
- How does the compiler decide between
intandfloatformula return types? By the declared channel type.channel float→Formula_*.Evaluatereturnsfloat;channel int→ returnsint. The engine'sRegisterFormulaoverload is selected accordingly. Onlyintformulas exist today. - See
03-channels-and-modifiers.mdfor howresolve(...)andscope.resolve(...)lower inside theEvaluatebody, and howreadsChannelsis extracted from the expression tree.
Cross-scope effects: modifier attachments
What: The only way a template affects a channel outside its activation root. A Building template (root_scope Location) that wants to raise settlement-scope Morale attaches a stackable modifier to settlement in OnBuilt. The binding owner is the building instance; the binding target is the settlement. When the building is destroyed, TemplateActivator.Destroy removes every modifier binding owned by that building, so the source does not need a matching OnDestroyed only to avoid leaks. The modifier's declaration lives next to other modifiers (see 03-channels-and-modifiers.md), carries stacking = stackable, and expresses the effect as Morale += 5 (or similar). The settlement's aggregated Morale is then the zero-base Contributed value plus every stackable modifier piled on it.
SECS:
template<Building> House
{
method void OnBuilt() { @Settlement.add_modifier HouseMoralePresence; }
}
with the matching modifier declaration (forward-syntax; full lowering in 03-channels-and-modifiers.md):
modifier HouseMoralePresence
{
translation = "House";
stacking = stackable;
Morale += 5;
}
Compiles to:
public static void OnBuilt(ITemplateCommandContext context, in SecsNoArgs args)
{
var settlement = context.Host.WalkScope(context.Scope.Root, H.Settlement);
context.Commands.AddModifier(context.Scope.Owner, settlement, H.HouseMoralePresence, triggerId: 0, duration: -1);
context.FlushCommands();
}
The real working pattern is DungeonAuraOfDread in Generated/Templates/Locations/Sites/Dungeon.cs lines 28-31: OnSpawned calls context.Commands.AddModifier(context.Scope.Owner, context.Scope.Root, H.DungeonAuraOfDread, triggerId: 0, duration: -1) and then context.FlushCommands(). Dungeon's target happens to equal its own scope (features sit at feature scope and the aura is authored to attach to the feature itself) so there is no WalkScope; when the target is a different scope the lowering adds context.Host.WalkScope(context.Scope.Root, H.<TargetScope>) ahead of the AddModifier.
Why this shape:
- Named effect → mod-operation target. Because
HouseMoralePresenceis a modifier declaration, a mod can replace or inject its slots without touching the House template or the settlement's base Morale. Mod operations compose; the aggregation does too. - Tooltip-attributable. Every stackable modifier contributes a named line to tooltips, with localization resolved by modifier id through the live localization layer. "Morale: 55 (20 base + 5 × 7 Houses)" falls out of the pile walk naturally.
- Stackable for aggregation.
stacking = stackablemeans re-applying the modifier adds another copy to the pile instead of refreshing or rejecting. N Houses contribute N stacks ofHouseMoralePresence, each worth+5Morale. Cross-scope aggregation stays in the modifier pile.
Lifecycle rule: Modifier ownership is the teardown contract. TemplateActivator.Destroy runs the contract's deactivation <Method>; lifecycle binding, then queues RemoveAllChannelSources(owner), RemoveAllBindings(owner), and RemoveAllBindingsOnTarget(owner). A building-owned settlement modifier comes off automatically when the building instance is destroyed, and a modifier targeted at a destroyed entity also comes off. Source-level remove_modifier remains useful for early removal or custom interactions, but it is not required for ordinary template lifetime cleanup.
Same-scope attach: When scope.add_modifier X resolves to the same scope as the template's root, the compiler can elide the WalkScope call. The Dungeon precedent shows it: a feature-scope template attaching a feature-scope modifier emits no WalkScope.
Contract queries and typed returns
What: A template method that implements a read-only query declared by the template's contract. CanBuild() is a Building contract query; EvaluatePlacement(Location candidate, FeaturePlacementInput input) is a Feature contract query that returns a declared record. Another contract might declare CanCast(), CanEquip(), CanPlace(), CostPreview(), or no queries at all. Queries are generic contract-call surfaces. They do not enqueue commands, spend resources, mutate host fields, or activate a template. The compiler emits each implemented query as a static method returning its declared concrete C# type and registers it by query id on the template entry.
SECS:
template<Building> Farm
{
// ... channels and fields
query bool CanBuild()
{
return @Settlement.Stage >= 1
&& @Settlement.Gold >= template_field(Farm, GoldCost, BuildCostContext)
&& @Settlement.Wood >= template_field(Farm, WoodCost, BuildCostContext)
&& @Settlement.Stone >= template_field(Farm, StoneCost, BuildCostContext)
&& @Settlement.Metal >= template_field(Farm, MetalCost, BuildCostContext);
}
}
(examples/valenar/Content/buildings/district/farm.secs)
Structured return example:
template<Feature> Dungeon
{
field FeaturePlacementProfile Placement = new(
Family: FeatureFamily.Threat,
MinScore: 170,
MaxPerWorld: 2,
MinSpacingRank: 2,
DefaultDiscoveryState: FeatureDiscoveryState.Hidden,
DefaultHostile: true,
HardRequirements: [
new(LocationFactKind.BaseThreat, PlacementComparison.GreaterThanOrEqual, 40),
],
// Wave R-4 (Valenar): AvoidanceWeights was folded into
// SuitabilityWeights as negated entries. The SECS surface keeps a
// single weight dictionary; pull-toward facts use positive weights
// and push-away facts use negative weights.
SuitabilityWeights: new()
{
[LocationFactKind.BaseThreat] = 5,
[LocationFactKind.Corruption] = 3,
[LocationFactKind.Fertility] = -1,
},
// Feature-placement *Tags are local string labels for placement
// heuristics, not SECS TagId structural tags.
ProducedTags: ["threat", "hostile-site"],
ConflictTags: ["sanctuary"],
SynergyTags: ["corruption"]);
query FeaturePlacementResult EvaluatePlacement(Location candidate, FeaturePlacementInput input)
{
return evaluate_feature_placement(input, candidate.Facts());
}
}
A second example shows a pure host query on the current location:
template<Building> Watchtower
{
query bool CanBuild()
{
return @Location.BuildingCount<Watchtower>() == 0
&& @Settlement.Gold >= template_field(Watchtower, GoldCost, BuildCostContext)
&& @Settlement.Wood >= template_field(Watchtower, WoodCost, BuildCostContext)
&& @Settlement.Stone >= template_field(Watchtower, StoneCost, BuildCostContext)
&& @Settlement.Metal >= template_field(Watchtower, MetalCost, BuildCostContext);
}
// ... channels
}
Compiles to:
public static readonly TemplateEntry Entry = new()
{
// ...
Queries = new Dictionary<ulong, ITemplateQuery>
{
[H.Contract_Building_CanBuild_NoArgs_Bool] = TemplateQuery.NoArgs(H.Contract_Building_CanBuild_NoArgs_Bool, SecsTypeRef.Bool, CanBuild),
},
};
public static bool CanBuild(ScopeFrame scope, ISecsHostReads host, in SecsNoArgs queryArgs)
{
var settlement = host.WalkScope(scope.Root, H.Settlement);
if (settlement.IsNull)
{
return false;
}
return
LocationScopeQueries.BuildingCount(host, scope.Root, Id) == 0
&& BuildCostRules.CanPay(Id, scope, host);
}
public static FeaturePlacementResult EvaluatePlacement(
ScopeFrame scope,
ISecsHostReads host,
in FeaturePlacementQueryArgs args)
{
var candidateFacts = FeaturePlacementQuery.ReadLocationFacts(host, args.Candidate);
return FeaturePlacementQuery.Evaluate(args.Input, candidateFacts);
}
(examples/valenar/Generated/Templates/Buildings/Watchtower.cs, lines 8-30)
Why this shape:
- Generated query methods return the declared concrete type: source
query bool CanBuild(...),query FeaturePlacementResult EvaluatePlacement(...),query CostPreview GetCostPreview(...), etc. lower to ordinary generated C# methods returningbool,FeaturePlacementResult,CostPreview, and so on. Dynamic registry adapters may erase that value for host/tooling calls, but the adapter validates againstContractMethodDeclaration.ReturnType: SecsTypeRef; the source/runtime contract is notSecsValueKind. A creation-time caller constructs the frame withScopeFrame.ForCreation(root), so the source frame binds@root = scope.Root;@thisand@ownerare not available. The compiler rewrites the method body so every scope walk, channel resolve, template-value read, and field read goes throughhost. - Query source signatures do not accept world-context parameters. A query body reads
@rootand declared walks from the root frame (@Location,@Settlement, etc.); it does not receiveLocation location,Settlement settlement,Owner owner, or equivalent entity handles from the caller. - Read-only scope methods get desugared into typed
ISecsHostReadscalls or typed dynamic adapters.@Location.BuildingCount<Watchtower>()becomes a location-scope host query returningint, with the template id supplied as aTemplateIdhelper argument. This is becauseBuildingCountis declared on theLocationscope asquery int BuildingCount(TemplateId templateId)— a read-only host-implemented method on a scope.method voidscope methods such as Valenar'sDrainStamina,GainXp,AdvanceLead, andRevealrequire a command context and lower throughISecsHostCommands; they are not legal insideCanBuildorEvaluatePlacement. - Template-value reads with context go through the host read boundary in generated queries.
template_field(Watchtower, GoldCost, context(@Settlement, @Location))lowers tohost.ResolveTemplateValueInt(H.Watchtower, H.GoldCost, contexts)with the normalized ordered context chain[settlement, location].template_field(Watchtower, GoldCost, BuildCostContext)lowers the same way after expanding the contract-owned profile. No extra context is implied by the template id, field id, query name, or receiver. - A query implementation is optional. Templates whose contract declares a query but intentionally omit an implementation receive the query's default policy from the generic contract-call API; templates whose contract declares no queries emit no query dictionary.
- Contracts declare query names (
CanBuildonBuilding,CanCastonSpell,CanPlaceonMachine). The compiler knows which template methods are queries by checking the contract declaration's query list, not by hard-codingCanBuild. Any templatevoidmethod whose name is in the contract's method list goes into the method dictionary; any template function that is in neither the query list nor the method list is rejected. - Queries are callable API. Host code can ask the registry to evaluate
(templateId, queryId, ScopeFrame, typed args)for UI, AI, enqueue, generation, and validation decisions, receiving the declared concrete return type on the read boundary. SECS code can call the same query surface from systems/events when it needs a dry-run value. A query must not enqueue commands or mutate host fields; use a void method or activity-effect command for side effects. CanBuildis not a SECS resource system. Gold/Wood/Stone/Metal are Valenar scope fields, and the repeated resource comparisons are normal Valenar code. Another game can use the same template-value resolver for mana, crafting reagents, card energy, spawn weight, upkeep, or no cost at all.- Mod scope note.
CanBuild's scope walks are checked against the merged scope graph (base + every loaded mod). If nowalks_toedge exists, the compiler emitsSECS0111; it does not generate a permissive fallback. If a declared walk returns null at runtime for this entity (for example, an unowned location has no settlement), the source/generated query decides the result.
Feature placement constraint: EvaluatePlacement(Location candidate, FeaturePlacementInput input) is a read-only local scoring query. It may return a FeaturePlacementResult that explains suitability and weight, but it must not reserve the candidate, decrement global budgets, enforce spacing against other accepted candidates, or mutate generation state. The feature-placement system collects query results and then applies global budgets, spacing, uniqueness, and conflict resolution in one deterministic pass. See doc 07.
Creation initial state ordering: when a system creates a template with authored initial fields, the generated/host flow must allocate the entity, establish parent/walk links, write typed initial scope fields, then run Activate and activation lifecycle methods. OnSpawned is allowed to read initial metadata such as PlacementScore, Risk, DiscoveryState, or Hostile; therefore post-create patching is not a valid lowering for create_entity ... with { ... } blocks.
Notes: None. Template-value source spelling is fixed as template_field(Template, Field) for base-only reads, template_field(Template, Field, context(...)) for explicit template-value reads, and template_field(Template, Field, ProfileName) for named contract-owned context profiles. Receiver-style field reads are not part of the committed surface because they hide the context-chain question too easily.
Contract methods and lifecycle bindings
What: Any void template body method that is listed in the contract's method set is a callable method. Lifecycle methods are not a separate source construct; they are ordinary contract methods selected by lifecycle bindings such as activation OnBuilt; or deactivation OnUnequipped;. The compiler emits each implemented method as a static method on the template class with a typed TemplateCommand<TArgs> wrapper and registers it in the method dictionary on TemplateEntry, keyed by the contract owner + full signature hash.
SECS:
template<Feature> Dungeon
{
channel int ThreatLevel = 50;
channel int LootValue = 200;
method void OnSpawned()
{
add_modifier DungeonAuraOfDread;
}
method void OnDiscovered() { }
method void OnInteract() { }
method void OnCleared()
{
remove_modifier DungeonAuraOfDread;
add_modifier DungeonClearedReward;
}
}
(examples/valenar/Content/locations/sites/dungeon.secs, lines 9-28)
Compiles to:
public static readonly TemplateEntry Entry = new()
{
TemplateId = Id,
Name = "Dungeon",
ContractId = H.FeatureContract,
RootScopeId = H.Feature,
Activate = Activate,
Methods = new Dictionary<ulong, ITemplateCommand>()
{
[H.Contract_Feature_OnSpawned_NoArgs_Void] = TemplateCommand.NoArgs(H.Contract_Feature_OnSpawned_NoArgs_Void, OnSpawned),
[H.Contract_Feature_OnCleared_NoArgs_Void] = TemplateCommand.NoArgs(H.Contract_Feature_OnCleared_NoArgs_Void, OnCleared),
},
};
public static void OnSpawned(ITemplateCommandContext context, in SecsNoArgs args)
{
context.Commands.AddModifier(context.Scope.Owner, context.Scope.Root, H.DungeonAuraOfDread, triggerId: 0, duration: -1);
context.FlushCommands();
}
public static void OnCleared(ITemplateCommandContext context, in SecsNoArgs args)
{
context.Commands.RemoveModifier(context.Scope.Owner, context.Scope.Root, H.DungeonAuraOfDread);
context.FlushCommands();
context.Commands.AddModifier(context.Scope.Owner, context.Scope.Root, H.DungeonClearedReward, triggerId: 0, duration: -1);
context.FlushCommands();
}
(examples/valenar/Generated/Templates/Locations/Sites/Dungeon.cs, lines 8-40)
Why this shape:
- Contract methods are command-producing and return
void. Every method compiles to the same runtime frame —context.Scope.Owner/context.Scope.Thisis the existing template instance,context.Scope.Rootis the contract's root scope, reads go throughcontext.Host, writes go throughcontext.Commands, and source parameters are validated againstContractMethodDeclaration.ParameterTypes: SecsTypeRef[]. Known generated call sites use typed argument structs such asSecsNoArgs; erased dynamic/tooling adapters may translate into those typed arguments after validatingSecsTypeRef, but normal template callable ABI is not an erased value span. In source,method void OnBuilt()still receives its world context through that scope frame. There is no source-levelmethod void OnBuilt(Location location, Settlement settlement, Owner owner)form. - The dictionary key is the contract owner + full signature id.
contract Building { method void OnBuilt(); }hashescontract:Building.OnBuilt():voidand emitsH.Contract_Building_OnBuilt_NoArgs_Void; every Building template that implementsOnBuilt()uses that same key. A different contract'sOnBuilt()would receive a different id. - The contract's callable signature metadata is emitted separately as
ContractMethodDeclaration[].ContractDeclaration.QueryMethodIdsandContractDeclaration.MethodIdsremain the runtime dispatch/lifecycle lists;ContractMethodDeclarationcarries the compiler-facing name, returnSecsTypeRef, parameterSecsTypeRefs, and query-vs-command bit. The registry requires those rows to match the contract lists, so metadata cannot silently add a callable surface. - The registry is also the runtime conformance gate for compiler output. Startup rejects duplicate ids, unknown scopes/contracts/callables, incomplete callable metadata, lifecycle bindings that do not target declared parameterless void methods, template entries whose root scope disagrees with their contract, and method/query dictionaries that point at undeclared or wrong-kind callables. These checks are generic SECS validation, not Valenar-specific rules.
- Method names are developer/game vocabulary, not engine vocabulary.
OnBuiltis Valenar's name for the Building contract's activation effect. A different game can declaremethod void OnEquipped(); activation OnEquipped;on an Equipment contract, ormethod void OnCrafted(); activation OnCrafted;on an Item contract. The emitted method dictionary and lifecycle binding shape is identical. - Empty bodies are elided. The Dungeon .secs declares
method void OnDiscovered() { }andmethod void OnInteract() { }as placeholders. Neither appears in the generatedMethodsdictionary: onlyOnSpawnedandOnCleared(the ones with real bodies) are registered. Same story for Granary'smethod void OnDestroyed() { }andmethod void OnDayTick() { }— the generatedGranary.csMethodsonly listsH.Contract_Building_OnBuilt_NoArgs_Void(seeGenerated/Templates/Buildings/Granary.cs, lines 16-20). This keeps the dispatch table small and skips invoking no-op methods per tick. The compiler should apply this elision automatically: empty body -> no dictionary entry -> no emitted static method. add_modifier X;with nowhenorforlowers tocontext.Commands.AddModifier(context.Scope.Owner, context.Scope.Root, H.X, triggerId: 0, duration: -1)followed bycontext.FlushCommands()at top level — trigger zero means "always active", duration -1 means "permanent". The modifier-binding grammar (when,for,while) and its full lowering live in03-channels-and-modifiers.md.- The
Methodsdictionary is initialised eagerly at class-construction time. BecauseTemplateEntry.Methodsis declaredIReadOnlyDictionary<ulong, ITemplateCommand>, the hand-written emission usesnew Dictionary<ulong, ITemplateCommand>() { ... }with typed wrappers such asTemplateCommand.NoArgs(...)— the compiler can use either collection expression[ ... ]or the explicit form. Current hand-written output uses both forms interchangeably; the compiler must canonicalise on one form and emit it identically regardless of source-set provenance. The canonical choice is the indexer form ([H.Contract_Building_OnBuilt_NoArgs_Void] = TemplateCommand.NoArgs(H.Contract_Building_OnBuilt_NoArgs_Void, OnBuilt),) — it is the form generated by--emitand the form the merger writes back when a mod operation rewrites the dictionary. Mod scope note. A merged template body emerges from base + zero or more mod operations; the dictionary init form must be identical regardless of which slot was written last, so re-merging on--order [A,B]vs--order [B,A]produces a byte-identicalMethods = { ... }line. See00-overview.md § "Compiler-output ordering convention"for the full canonicalisation contract. - A lifecycle binding is an automatic caller of a method dictionary entry. The contract stores
LifecycleBindings = [Activation -> H.Contract_Building_OnBuilt_NoArgs_Void]; activation code asks the contract which method id is bound toContractLifecycleIds.Activation, then invokes that method through the same generic method-call path. Missing implementation or elided empty body is a no-op. No engine branch checks for the literal nameOnBuilt. - The same target rule applies to future or less-used lifecycle slots. If an Equipment contract binds
deactivation OnUnequipped;, the runtime resolvesContractLifecycleIds.Deactivation -> H.Contract_Equipment_OnUnequipped_NoArgs_Voidand invokes that method throughTemplateEntry.Methods. There is no separate template delegate path for deactivation.
Notes:
remove_modifierinside a method body is the explicit early/custom removal form. Ordinary modifier lifetime still relies on owner/target cleanup, but gameplay transitions such as item unequip may remove a binding before the owner or target is destroyed.- The modifier-attachment lifetime pattern is resolved:
owneris the lifetime source andtargetis the effect anchor. A mod overriding anOnBuiltattach site cannot leak the old binding by forgetting a pairedOnDestroyed, because ordinary cleanup is keyed by the runtime binding owner rather than a second source method. - Which lifecycle slots beyond
activationanddeactivationshould exist is deferred until a concrete generic need appears. Transition hooks are the likely next candidate, but they should be added as engine lifecycle ids only if they are useful across game genres.
Templates under registry_only contracts
What: Specified future/current implementation-gap contract qualifier for metadata-only templates. The intended design is that a registry_only qualifier inside a contract body suppresses entity activation entirely for that contract's templates. Templates of a registry_only contract register their TemplateId and structured field metadata but never produce an EntityHandle, never run Activate, and never invoke lifecycle methods. Looking up such a template is a registry metadata read (registry[id].GetField(...), registry[id].GetPlacement(), etc.), not an entity walk. registry[id] itself is not limited to registry_only; this qualifier only says the looked-up template cannot be activated. The canonical use case is recipe-style data: a RecipeEntry template has no world presence, no channels, no lifecycle — it is a typed bag of field values the host reads by template id.
SECS — contract declaration (contract syntax belongs in 01-world-shape.md territory; shown here for context):
contract Recipe
{
registry_only;
root_scope None;
}
template<Recipe> IronSword
{
field ItemId Result = ItemId.IronSword;
field int GoldCost = 15;
field int IronCost = 3;
}
(examples/valenar/Content/common/recipes.secs — illustrative)
Future intended compile shape — contract declaration:
new ContractDeclaration
{
ContractId = H.RecipeContract,
RootScopeId = H.None,
IsRegistryOnly = true,
QueryMethodIds = [],
MethodIds = [],
LifecycleBindings = [],
}
Current executable stand-in — template metadata plus no-op activation workaround:
public static readonly TemplateEntry Entry = new()
{
TemplateId = Id,
Name = "IronSword",
ContractId = H.RecipeContract,
RootScopeId = H.None,
GetFieldInt = GetField,
Activate = NoOpActivate,
};
ContractDeclaration.IsRegistryOnly, null/omitted Activate, activation/create rejection, generated output, and end-to-end tests remain future engine/compiler work. Current stand-ins use a no-op activation delegate plus content/host convention; track the gap in FUTURE_WORK.md.
Why this shape:
registry_onlyis a contract qualifier, not a template-body keyword. It is declared once on the contract; every template of that contract inherits the intended suppression automatically. The eventual engine/compiler contract isContractDeclaration.IsRegistryOnly = trueplus activation/create rejection. That is still a future end-to-end gap today.- The target compiler/runtime shape omits
TemplateEntry.Activatefor registry-only templates. Current stand-ins still wire a no-op activation delegate because the live registry validation path requiresActivate != null. registry_onlyis about activation suppression, not registry accessor eligibility.registry[id]may read metadata for any registered template; once the gap is closed,create_entity/CreateEntitymust reject registry-only templates because they have no activation path.- Host code reads a registry-only template by id:
registry[H.IronSword].GetFieldInt(H.GoldCost). It never callsActivate(H.IronSword, ...).StructuredFieldsand all scalar field accessors are fully populated. registry_onlycontracts are intended not to declare lifecycle bindings, queries, or methods. The future compiler/runtime handoff reservesSECS0213for that rule once the contract is implemented end to end.- The
root_scopeof aregistry_onlycontract must beNone(or a scope with no runtime creation). The compiler enforces this to prevent activation code from trying to walk a non-existent scope root. registry_onlytemplates participate in the mod-operation system normally.inject template IronSword { field GoldCost = 12; }rewrites only the scalar field value;IsRegistryOnlyis inherited from the contract and is not per-template-overridable.
Cross-reference: Full recipe-system / registry-metadata discussion — see 08-collections-and-propagation.md § "Part 7 (Recipe System / registry metadata)".
Notes:
- Should
registry_onlytemplates appear in the Lowering summary table? Yes — see the table entry added in the Lowering summary.
Bare templates (one per scope)
What: The engine requires every scope that can be created at runtime to have
at least one registered template. The compiler emits one Bare template per
creatable scope — Region, Area, Province, Location, Character are the current
set. Source code creates these structural entities with the same
create_entity primitive used for Character skills: create_entity BareRegion,
region.create_entity BareArea, province.create_entity BareLocation, and so
on. The Bare template is the no-op fallback: empty Activate, no per-instance
channels, no methods, identity only.
The module also registers each Bare template as the scope's default template via
RegisterDefaultTemplate(scopeId, bareTemplateId). That registry mapping is a
host/runtime convenience, not a separate SECS source primitive; generated source
lowering should still prefer explicit create_entity BareX calls.
SECS: No .secs source. The compiler generates Bare templates automatically from the scope declarations plus the contract-to-scope mapping.
Compiles to:
// Source: implicit — bare structural template for create_entity BareRegion.
// The SECS compiler emits one bare template per scope that needs runtime creation support.
namespace Valenar.Generated.Templates.Bare;
public static class BareRegion
{
public const ulong Id = H.BareRegion;
public static readonly TemplateEntry Entry = new()
{
TemplateId = Id,
Name = "Region",
ContractId = H.RegionContract,
RootScopeId = H.Region,
Activate = Activate,
};
/// <summary>
/// No intrinsic channel sources — regions are structural hierarchy nodes only.
/// Their identity comes from scope fields (OwnerId, children, etc.) managed by the host.
/// </summary>
public static void Activate(ScopeFrame scope, ISecsHostReads host, CommandBuffer cmds)
{
// Intentionally empty.
}
}
(examples/valenar/Generated/Templates/Bare/BareRegion.cs)
Registered in SecsModule.cs:
registry.RegisterTemplate(BareRegion.Id, BareRegion.Entry);
registry.RegisterTemplate(BareArea.Id, BareArea.Entry);
registry.RegisterTemplate(BareProvince.Id, BareProvince.Entry);
registry.RegisterTemplate(BareLocation.Id, BareLocation.Entry);
registry.RegisterDefaultTemplate(H.Region, BareRegion.Id);
registry.RegisterDefaultTemplate(H.Area, BareArea.Id);
registry.RegisterDefaultTemplate(H.Province, BareProvince.Id);
registry.RegisterDefaultTemplate(H.Location, BareLocation.Id);
(examples/valenar/Generated/SecsModule.cs, lines 96-104)
Why this shape:
- The Bare template is a minimal
TemplateEntry:TemplateId,Name,ContractId,RootScopeId,Activate. No query dictionary (there are no Bare-specific queries). NoMethods. NoGetFieldInt. An empty Activate body. - The compiler needs the contract → scope mapping to emit these.
BareRegion's contract isH.RegionContractand its root scope isH.Region— both come from the contract declaration forRegion. If a scope has no contract declared (like the ad-hochostscope), no Bare is emitted. BareLocationis the interesting outlier: even though it's "bare", it registersSlots = 4as an intrinsic flat channel source (seeGenerated/Templates/Bare/BareLocation.cs, lines 22-26). The compiler-side rule is "emit Bare with an empty Activate unless the scope has a declared intrinsic that applies to every entity of that scope". For Locations, the declaration (on the scope or on the contract) says "every location has 4 building slots by default". That declaration lowers into the Bare template's Activate because there's no better place for it — if you added a non-Bare Location template tomorrow, it would also registerSlots = 4unless it overrode. This is a grey area: it could equally well live as a scope-field default, but the current convention plants it on the Bare template.BareCharacteris registered but is not the subject ofRegisterDefaultTemplate(H.Character, BareCharacter.Id)alone — the module also registersMainCharacterseparately. Characters useRegisterDefaultTemplate(H.Character, BareCharacter.Id)for host/runtime convenience, and the host explicitly passesMainCharacter.Idwhen creating the player (seeGenerated/SecsModule.cs, lines 107-110).- Namespace is
Valenar.Generated.Templates.Bare. File name isBare{Scope}.cs. Hash id isH.Bare{Scope}— one entry per bare template inGenerated/Hashes.cs. - The compiler emits one Bare template even when a user
.secsfile already exists for the same scope. For example,characters/main_character.secsdeclares aBareCharacterexplicitly (lines 7-11) because the hand-written stand-in needed the Bare to live next to MainCharacter for code-organization reasons. The compiler's real rule is simpler: emit Bare unconditionally from the scope list, then let user.secstemplates co-exist.
Notes:
- Should scopes that exist but are never created at runtime (tell me if there are any) still get a Bare? Current answer: emit for every scope listed in the contract set. No cost to having unused Bares.
- Where does
Slots = 4on BareLocation actually come from semantically? The .secs has no declaration for it. This is a hand-written-only artifact — the compiler needs the source of that default, likely as a scope-level declaration in01-world-shape. Tracked as an inconsistency until the declaration lives somewhere.
Mod scope note. Bare templates are emitted as a universal invariant of the merger: for every scope declared in the merged set (base ∪ all loaded mods), the compiler emits exactly one Bare template automatically. A mod that adds scope OrbitalStation to the merged graph causes the merger to emit BareOrbitalStation with the same canonical shape — empty Activate, no query dictionary, no Methods, name derived by stripping the Bare prefix from the class identifier. The mod author does not declare BareOrbitalStation in .secs; if they try, it is treated as a duplicate and emits SECS0602. To customise a Bare's defaults (e.g. raise BareLocation.Slots from 4 to 8 for a mod that ships larger maps), the mod uses inject template BareLocation { ... } — Bare templates are mod-operation targets like any other template. The hand-written BareCharacter in Content/characters/main_character.secs is a legacy artifact of the stand-in; the compiler will subsume it. Cross-reference 06-overrides-and-modding.md § "Slot schema" for the Bare-template slots.
Source-comment and docstring conventions
What: Every generated template file opens with a // Source: <path> comment that names the .secs file it came from. Non-trivial emissions (systems, formulas, sometimes templates) also carry a /// <summary> ... /// Compiled from: <verbatim .secs body> ... </summary> XML docstring that preserves the original SECS source for human readers and for round-trip debugging. Inline single-line comments preserve the source form of individual statements (// .secs: channel int FoodOutput { ... } above the corresponding cmds.Register... call).
SECS: N/A — this is an output convention the compiler imposes, not something the .secs author writes.
Compiles to:
File header (every generated template):
// Source: Content/buildings/district/farm.secs
namespace Valenar.Generated.Templates.Buildings;
public static class Farm
{
// ...
}
(examples/valenar/Generated/Templates/Buildings/District/Farm.cs, line 1)
Bare template header (no .secs source):
// Source: implicit — bare structural template for create_entity BareRegion.
// The SECS compiler emits one bare template per scope that needs runtime creation support.
(examples/valenar/Generated/Templates/Bare/BareRegion.cs, lines 1-2)
Inline source-preservation (every non-trivial lifecycle lowering):
public static void OnBuilt(ITemplateCommandContext context, in SecsNoArgs args)
{
// .secs: @Settlement.add_modifier HousePopulationCapStack when always;
// .secs: @Settlement.add_modifier HouseMoralePresence when always;
var settlement = context.Host.WalkScope(context.Scope.Root, H.Settlement);
if (!settlement.IsNull)
{
context.Commands.AddModifier(context.Scope.Owner, settlement, H.HousePopulationCapStack);
context.FlushCommands();
context.Commands.AddModifier(context.Scope.Owner, settlement, H.HouseMoralePresence);
context.FlushCommands();
}
}
(examples/valenar/Generated/Templates/Buildings/Settlement/House.cs, OnBuilt)
Full docstring (used for complex lowerings — see TaxCollectionSystem precedent):
/// <summary>
/// Compiled from: system TaxCollection { phase = Phases.Production; frequency = Cadence.Monthly; ... }
///
/// Load distribution: the system runs every tick (Frequency=1) but only processes
/// entities where (entityId + tick) % 30 == 0, spreading monthly work across 30 ticks.
/// The .secs author writes "frequency = Cadence.Monthly;" — the compiler handles the rest.
/// </summary>
(examples/valenar/Generated/Systems/Economy/TaxCollectionSystem.cs, lines 4-10)
Why this shape:
- The header comment is load-bearing for the hand-written stand-in: it is the only trail from generated C# back to the
.secssource when you're debugging a failing test or auditing what the hand-written output does. The compiler replaces this trail with proper source maps, but the human-readable header should persist so that a reader of the generated code can always find the source. - The
/// Compiled from: <verbatim>docstring is only emitted when it is cheap and useful: for formulas (short, dense), for systems (complex macros over phase/frequency), and optionally for templates with non-trivial per-instance derived channels. For a template with only per-instance flat channels, the inline// .secs:comments are enough. - The inline
// .secs: ...comment should reproduce a single SECS source line (or a condensed multi-line expression) above the C# statement it lowers from. The compiler emits these verbatim when possible; if the source spans many lines, it condenses into one (dropping whitespace and comments). - No comment leaks the
.secspath twice. The header has it; inline comments do not.
Notes:
- Should the compiler embed source-range metadata (line/col) in a sidecar
.mapfile? Likely yes; not yet decided. The Roslyn fork already has infrastructure for pdb-style source maps.
reads { } dependency hint
What: An optional block at the top of a template body that lists the channels the template depends on. Every Farm in the Valenar content has it:
template<Building> ExampleWorkshop
{
reads { FoodOutput }
// ...
}
Semantically, reads { S1, S2 } announces "this template's per-instance derived channels, contract queries, and contract methods may call resolve(S1) or resolve(S2) when they run". The engine uses this for cycle detection during registry.ValidateDependencies() and for invalidation when S1 or S2 changes (so cached resolved values that depend on it get marked dirty).
SECS:
template<Building> ExampleWorkshop
{
reads { FoodOutput }
channel int ProductionOutput
{
return resolve(FoodOutput) / 2;
}
// ... rest of body
}
Hypothetical in-scope example; no current Valenar template needs a manual reads block.
Compiles to: Nothing visible on the template. Check any of the generated templates — Farm.cs, IrrigatedFarm.cs, TerracedFarm.cs — none of them emits a reads list on the TemplateEntry. The information is extracted and moved to the per-formula registration call:
registry.RegisterFormula(H.Formula_ExampleWorkshopProductionOutput, Formula_ExampleWorkshopProductionOutput.Evaluate,
readsChannels: [H.FoodOutput]);
The exact formula name is illustrative; the emission shape is the point.
Why this shape:
- The SECS compiler's semantic rule is: "auto-extract
readsChannelsfrom the expression tree; thereads { ... }block is a manual override only". So thereads { FoodOutput }block above is redundant with what the compiler would infer by walkingresolve(FoodOutput)— it exists in the source as an affirmation and as documentation, not as the authoritative source. - When the block is present and the inference disagrees, the compiler should emit a warning. The authoritative dependency list is the inferred one, because the inference is conservative (it never under-approximates).
- The template file emits nothing about
reads— all dependency metadata ends up next to the formula it describes. This is because dependency tracking is per-formula, not per-template: a single template can have two dynamic channels reading completely different scope fields. - If a template carries
reads { ... }but has no formula that needs it, the compiler should warn that the block is redundant rather than emitting template-level metadata.
Notes:
- Should the template-level
readslist be surfaced as metadata onTemplateEntryfor tooling (so a debugger UI can say "this template depends on Fertility" without walking into its formulas)? Currently it isn't emitted at all. Arguments for: better for step-through debugging, better for cycle-diagnostic messages. Arguments against: it's duplicative with per-formulareadsChannels, and the per-formula form is what the engine actually uses. No decision made; emit-nothing is the current convention. - If
readsis manual-override, should the compiler emit an error or a warning when it diverges from inference? Warn-only is probably right; the human intent is sometimes broader than inference catches.
Mod scope note. Auto-extraction runs on the merged formula body, not on the base body. Only resolve(ChannelName) calls become readsChannels edges. Raw scope-field reads such as @Location.Fertility do not enter the channel dependency graph. When a mod operation changes a template's dynamic channel (inject template Farm { channel int FoodOutput { return @Location.Fertility + resolve(Blight); } }), the merger emits the winning body into Formula_FarmFood.cs, then re-runs auto-extraction on that emitted body to produce the final readsChannels: array. The matching registry.RegisterFormula(H.Formula_FarmFood, ..., readsChannels: [...]) call in SecsModule.cs is rewritten by the merger to reflect the union of auto-extracted channel reads plus any explicit reads { } block. This is consequential: a mod operation that adds resolve(Blight) introduces a new edge Blight → Formula_FarmFood in the dependency graph. ValidateDependencies() runs on the merged registry after Phase 3 merge, so a mod-introduced edge that creates a cycle is caught at startup, not at first read. The cycle error surfaces through the merger's conflict report (06-overrides-and-modding.md § "Conflict report shape") framed as a "mod introduced cycle" entry rather than a slot conflict. See 03-channels-and-modifiers.md § "reads { } dependency hint — D-Hybrid rule" for the full D-Hybrid rule that drives the merge-time auto-extraction.
Template transition / replace edges
What: A template-body structural declaration that says an existing entity currently using template A may replace that template with template B. These are generic transition/replaces edges, not Valenar building upgrades: a game can use them for tech tiers, card evolution, unit promotion, item reforging, spell morphs, state machines, or anything else where the same entity keeps its identity while its template changes. Branching is first-class: many target templates may each declare replaces SameSource, and each edge may name an optional contract query that decides eligibility.
SECS:
template<Building> IrrigatedFarm
{
replaces Farm
{
requires CanIrrigateFarm;
}
field int GoldCost = 25;
field int WoodCost = 20;
query bool CanIrrigateFarm()
{
return @Settlement.Population >= 20
&& @Settlement.Gold >= template_field(IrrigatedFarm, GoldCost, BuildCostContext)
&& @Settlement.Wood >= template_field(IrrigatedFarm, WoodCost, BuildCostContext);
}
}
template<Building> TerracedFarm
{
replaces Farm
{
requires CanTerraceFarm;
}
field int GoldCost = 40;
field int StoneCost = 10;
query bool CanTerraceFarm()
{
return @Location.Slope >= 40
&& @Settlement.Gold >= template_field(TerracedFarm, GoldCost, BuildCostContext)
&& @Settlement.Stone >= template_field(TerracedFarm, StoneCost, BuildCostContext);
}
}
The no-query form is the same structural edge with always-true eligibility:
template<Building> StorehouseTier2
{
replaces Storehouse;
field int GoldCost = 40;
field int WoodCost = 60;
}
The archive draft used upgrade and cost Gold = 50; syntax. That is historical only. Current design keeps resource numbers as readonly template field values plus normal boolean queries. The transition edge stores the optional query id/delegate; a target template's creation-time query such as CanBuild does not decide whether an existing source may transition into it, and CanBuild is not reused as a universal transition gate.
In a concrete source set, CanIrrigateFarm and CanTerraceFarm must be declared as queries by the owning contract. They are edge-specific query surfaces, not void methods.
Compiles to: a structural transition table keyed by (fromTemplateId, toTemplateId), plus a query id/delegate when the edge names one:
public static readonly TemplateTransitionDeclaration[] TemplateTransitions =
[
new() { FromTemplateId = H.Farm, ToTemplateId = H.IrrigatedFarm, QueryId = H.CanIrrigateFarm, CanTransition = IrrigatedFarm.CanIrrigateFarm },
new() { FromTemplateId = H.Farm, ToTemplateId = H.TerracedFarm, QueryId = H.CanTerraceFarm, CanTransition = TerracedFarm.CanTerraceFarm },
new() { FromTemplateId = H.Storehouse, ToTemplateId = H.StorehouseTier2 },
];
registry.RegisterTemplateTransitions(Declarations.TemplateTransitions);
The query method lowers with the read-only transition signature: (ScopeFrame scope, ISecsHostReads host) -> bool. scope.Owner is the existing entity whose template may change; scope.Root is the contract root target. In source, the query still has no world-context parameters: this / owner bind to the existing entity, root binds to the root-scope target, and location / settlement / other declared scope sigils walk from that frame. Host code can query CanTransition(entity, H.IrrigatedFarm) before showing a button or committing an order; SECS code can query the same edge in systems/events when it has an existing owner entity. Executing the edge is a separate write operation (TemplateActivator.Transition in the runtime), not an implicit side effect of evaluating the query.
Why this shape:
- Edges are structural metadata, not fields. Do not model "next tier" as
field ulong UpgradeTargetorfield int Tier. Template fields are readonly/effective scalar data; transition topology is a graph over templates and belongs in a transition declaration table. Field modifiers can discountGoldCost; they cannot add or remove outgoing transition edges. - Branching is natural.
IrrigatedFarmandTerracedFarmcan both sayreplaces Farm; a character may transition to several classes; an item may reforge along several paths. Each edge is addressed independently as(from, to)and has its own optional query binding. - The query binding belongs to the edge.
CanIrrigateFarmanswers only "may Farm become IrrigatedFarm here?" It is not the target template's creation-time query, and it is not inherited by every other edge intoIrrigatedFarm. - Transition lifecycle is not entity destruction. Executing a transition keeps the same
EntityHandle, host scope record, saved references, ownership links, and external relationships. It removes/replaces the old template activation's channel sources and template-owned bindings, activates the new template on the same entity, and may fire explicit transition hooks if the contract later declares them. It must not call destruction-only hooks, delete scope fields, remove the entity from host collections, or spawn a second entity as a workaround. - Queries remain contract/API surfaces. A query named by an edge must be declared by the template's contract and is callable by host/SECS through the same generic query evaluation boundary as
CanBuild.
Notes:
- Final block spelling for query-gated edges. The current target is
replaces Source { requires QueryName; }; the no-query shorthandreplaces Source;is already used in Valenar's hand-written.secscontent. - Whether transition declarations should additionally emit query method ids for tooltip/provenance. Current runtime table stores the delegate; diagnostics/provenance may need a
QueryMethodIdlater. - Whether transition hooks should be declared as contract methods (
OnTransitionOut,OnTransitionIn) or as edge-local effects. Current target: defer hooks until a concrete game needs side effects beyond replacing template-owned sources/bindings.
Mod scope note (when transitions land). Transition edges are keyed by (fromTemplateId, toTemplateId). A mod may add a new outgoing edge from a base template to a mod template, or replace an existing edge's query binding. Resource numbers are not a separate template_transition_cost surface: they remain template_field slots on the source or target template, so inject template IrrigatedFarm { field GoldCost = 45; } changes the edge query's effective-cost read without touching the edge itself. Two mods that both add or replace the same (from, to) edge report a template_transition_edge conflict through the standard conflict report; replacing the query body itself is a template_query_method slot write on the target template.
Identity-only templates (MainCharacter and name = ...)
What: Some templates exist only to give an entity a name and a contract binding — no intrinsic channel sources, no lifecycle logic, no constants or non-channel metadata. The player's main character in Valenar is the canonical example: it is created once, it needs to be distinguishable from a Bare character, and today it carries only a display name. Future revisions will add real channels.
SECS:
template<Character> BareCharacter
{
// Bare default — used when no specific character template is specified.
// No intrinsic channel sources; identity only.
}
template<Character> MainCharacter
{
name = "Hero";
// Placeholder: future intrinsic channels (Strength, Agility, etc.) go here.
}
(examples/valenar/Content/characters/main_character.secs)
Compiles to:
// Source: Content/characters/main_character.secs
// The player's protagonist. Spawned once at world generation.
// Name is declared here as template metadata (templates-as-data principle).
// CurrentLocationId is a host-owned scope field — never set from template code.
namespace Valenar.Generated.Templates.Characters;
public static class MainCharacter
{
public const ulong Id = H.MainCharacter;
public static readonly TemplateEntry Entry = new()
{
TemplateId = Id,
Name = "Hero",
ContractId = H.CharacterContract,
RootScopeId = H.Character,
Activate = Activate,
};
public static void Activate(ScopeFrame scope, ISecsHostReads host, CommandBuffer cmds)
{
// Placeholder: future intrinsic channels (Strength, Agility, etc.) go here.
// Name is carried by TemplateEntry.Name — host reads it at creation time.
}
}
(examples/valenar/Generated/Templates/Characters/MainCharacter.cs)
Why this shape:
name = "Hero";in SECS is not a field assignment and not an intrinsic channel source. It is template metadata that lowers directly into theNameinit field onTemplateEntry. The compiler treatsname(lower-case) as a reserved keyword inside template bodies; it must be a string literal; it must appear at most once per template.- If a template declares
name = "Hero";, theNameinit onTemplateEntryuses that string. If a template does not declarename = ...;, the compiler falls back to the C# identifier (Name = "Farm"fortemplate<Building> Farm). Bare templates follow the same rule —BareRegionhas noname = ...and getsName = "Region"by default (seeGenerated/Templates/Bare/BareRegion.cs, line 13:Name = "Region", derived from stripping theBareprefix). Activateis emitted even for identity-only templates. The body is empty but the delegate wire-up is not optional —TemplateEntry.Activateis a required init property on the engine side.- The namespace picks up
.Characters(from thecharacters/content sub-tree). Same mechanical mapping as Buildings/Features. - The
Namefield onTemplateEntryis not localizable. Localization keys are separate and live in the localization YAML.Nameis for debugging, logging, and UI fallbacks when no localization provider is configured. Do not treat it as the canonical display name for UI.
Notes:
- Should
nameaccept an expression (e.g.name = "Hero " + Level;)? No — the init field is a plain string onTemplateEntryand the compiler can't support runtime-varying values here. Dynamic names belong in a host-side display-name system. - Should other metadata fields (icon, description, category) follow the same pattern? Probably yes when added, each becoming a dedicated init field on
TemplateEntryrather than a dictionary.
Mod scope note. name = "X" is a per-field slot — the slot identifier is template_name_metadata in the merger's per-slot registry. A mod that wants to rename MainCharacter from "Hero" to "Zero" writes inject template MainCharacter { name = "Zero"; }. The merger replaces only the Name init field; everything else on MainCharacter.Entry stays put. The string must be a non-empty literal (empty "" triggers the same compiler check that applies to base — SECS0204: template name metadata cannot be empty). Two mods that both write MainCharacter.name resolve via standard last-writer-wins by load order, with both writes reported in the conflict report (06-overrides-and-modding.md § "Conflict report shape"). Cross-reference 06-overrides-and-modding.md § "Slot schema" for the full per-slot enumeration covering every metadata field on TemplateEntry.
Composite templates: Barracks as a whole
Barracks exercises every template feature at once: per-template cost fields, a CanBuild query, and an OnBuilt lifecycle method that attaches settlement modifiers. Its settlement effects are not template-body channel declarations because Barracks is location-rooted while Garrison and Defense are settlement channels.
The code block below is the current Valenar typed stand-in shape. The CanBuild implementation returns bool directly and the query registration wraps that concrete method in a typed query adapter; the surrounding template/channel/modifier lowering remains the same.
// Source: Content/buildings/district/barracks.secs
namespace Valenar.Generated.Templates.Buildings;
public static class Barracks
{
public const ulong Id = H.Barracks;
public static readonly TemplateEntry Entry = new()
{
TemplateId = Id,
Name = "Barracks",
ContractId = H.BuildingContract,
RootScopeId = H.Location,
Activate = Activate,
Queries = new Dictionary<ulong, ITemplateQuery>
{
{ H.Contract_Building_CanBuild_NoArgs_Bool, TemplateQuery.NoArgs(H.Contract_Building_CanBuild_NoArgs_Bool, SecsTypeRef.Bool, CanBuild) },
},
GetFieldInt = GetField,
Methods = new Dictionary<ulong, ITemplateCommand>()
{
{ H.Contract_Building_OnBuilt_NoArgs_Void, TemplateCommand.NoArgs(H.Contract_Building_OnBuilt_NoArgs_Void, OnBuilt) },
},
};
public static void Activate(ScopeFrame scope, ISecsHostReads host, CommandBuffer cmds)
{
}
public static void OnBuilt(ITemplateCommandContext context, in SecsNoArgs args)
{
var settlement = context.Host.WalkScope(context.Scope.Root, H.Settlement);
if (!settlement.IsNull)
{
context.Commands.AddModifier(context.Scope.Owner, settlement, H.BarracksGarrison);
context.FlushCommands();
context.Commands.AddModifier(context.Scope.Owner, settlement, H.BarracksDefense);
context.FlushCommands();
context.Commands.AddModifier(context.Scope.Owner, settlement, H.Fortified, H.Trigger_DefenseGte50, -1);
context.FlushCommands();
}
}
public static bool CanBuild(ScopeFrame scope, ISecsHostReads host, in SecsNoArgs queryArgs)
{
var settlement = host.WalkScope(scope.Root, H.Settlement);
if (settlement.IsNull)
{
return false;
}
return LocationScopeQueries.BuildingCount(host, scope.Root, Id) == 0
&& BuildCostRules.CanPay(Id, scope, host);
}
public static int GetField(ulong fieldId) => fieldId switch
{
H.GoldCost => 30,
H.WoodCost => 20,
H.StoneCost => 15,
H.MetalCost => 20,
_ => 0,
};
}
Corrected lowering target derived from examples/valenar/Generated/Templates/Buildings/District/Barracks.cs.
What to notice in the emission order:
- The
Entry = new() { ... }initializer comes first, with every delegate referenced by method group. The compiler always lists the init fields in the same canonical order:TemplateId,Name,ContractId,RootScopeId,Activate,Queries(if any),Methods(if any), field accessors (if any). Any field not needed is omitted, not set to null. Lifecycle behavior is represented by contractLifecycleBindingsplusMethods, not by special per-lifecycle fields. This keeps diffs clean. - The static method declarations follow in the same order:
Activate, thenOnBuilt(lifecycle), thenCanBuild(query). The compiler does not sort alphabetically; it preserves the logical emission order. This matches the order a reader scanning the file expects: "construction, then post-construction event, then read-only availability query". Trigger_DefenseGte50is a compiled trigger — a separate concern that lives inGenerated/Triggers/. The modifier command@Settlement.add_modifier Fortified when @Settlement.resolve(Defense) >= 50lowers to aWalkScope(..., H.Settlement), a trigger entry (Trigger_DefenseGte50), and theAddModifiercall referencing it. Trigger lowering is covered in03-channels-and-modifiers.md.- The resource costs are
field intdeclarations, so they route throughGetFieldInt = GetField; they are not registered as channel sources inActivate.
End-to-end example: Farm
The canonical template. One .secs file declares three variants; the compiler emits three template classes plus three formula classes and threads them into SecsModule.
SECS source
template<Building> Farm
{
channel int FoodOutput
{
return (int)(5 * @Location.Fertility / 33);
}
field int GoldCost = 10;
field int WoodCost = 15;
query bool CanBuild()
{
return @Settlement.Stage >= 1
&& @Settlement.Gold >= template_field(Farm, GoldCost, BuildCostContext)
&& @Settlement.Wood >= template_field(Farm, WoodCost, BuildCostContext)
&& @Settlement.Stone >= template_field(Farm, StoneCost, BuildCostContext)
&& @Settlement.Metal >= template_field(Farm, MetalCost, BuildCostContext);
}
}
template<Building> IrrigatedFarm
{
channel int FoodOutput
{
return (int)(8 * @Location.Fertility / 33);
}
field int GoldCost = 25;
field int WoodCost = 20;
}
template<Building> TerracedFarm
{
channel int FoodOutput
{
return (int)(12 * @Location.Fertility / 33);
}
field int GoldCost = 40;
field int WoodCost = 30;
field int StoneCost = 10;
}
(examples/valenar/Content/buildings/district/farm.secs, lines 1-44 in full)
Generated template — Farm.cs
This is the current typed query shape: CanBuild returns bool directly and the query registration is keyed by the same contract method id the contract declaration exposes.
// Source: Content/buildings/district/farm.secs
namespace Valenar.Generated.Templates.Buildings;
public static class Farm
{
public const ulong Id = H.Farm;
// Stage 1: Farm is available from founding
private const int MinStage = 1;
public static readonly TemplateEntry Entry = new()
{
TemplateId = Id,
Name = "Farm",
ContractId = H.BuildingContract,
RootScopeId = H.Location,
Activate = Activate,
Queries = new Dictionary<ulong, ITemplateQuery>
{
[H.Contract_Building_CanBuild_NoArgs_Bool] = TemplateQuery.NoArgs(H.Contract_Building_CanBuild_NoArgs_Bool, SecsTypeRef.Bool, CanBuild),
},
GetFieldInt = GetField,
};
public static void Activate(ScopeFrame scope, ISecsHostReads host, CommandBuffer cmds)
{
// Dynamic: FoodOutput scales with location Fertility
// .secs: channel int FoodOutput { return (int)(5 * @Location.Fertility / 33); }
cmds.RegisterDynamicChannelSource(scope.Owner, scope.Root, H.FoodOutput, H.Formula_FarmFood);
}
public static bool CanBuild(ScopeFrame scope, ISecsHostReads host, in SecsNoArgs queryArgs)
{
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);
}
public static int GetField(ulong fieldId) => fieldId switch
{
H.GoldCost => 10,
H.WoodCost => 15,
_ => 0,
};
}
Corrected lowering target derived from examples/valenar/Generated/Templates/Buildings/District/Farm.cs.
Generated formula — Formula_FarmFood.cs
// Source: Content/buildings/district/farm.secs
namespace Valenar.Generated.Formulas;
/// <summary>
/// Farm food production scales with location Fertility (0..100 geographic value).
/// Compiled from: channel int FoodOutput { return (int)(5 * @Location.Fertility / 33); }
///
/// Parameters:
/// target = settlement (entity whose channel is being resolved)
/// owner = farm building (the channel source owner, used for scope walking)
/// </summary>
public static class Formula_FarmFood
{
public static int Evaluate(EntityHandle target, EntityHandle owner, EntityHandle captured1, ISecsHostReads host)
{
var location = host.WalkScope(owner, H.Location);
if (location.IsNull) return 5;
var fertility = host.ReadInt(location, H.Location, H.Fertility);
return Math.Max(1, (int)(5.0 * fertility / 33));
}
}
(examples/valenar/Generated/Formulas/Formula_FarmFood.cs, lines 1-25 in full)
Generated registrations — slice of SecsModule.cs
// Formula registration block
registry.RegisterFormula(H.Formula_FarmFood, Formula_FarmFood.Evaluate,
readsChannels: []);
registry.RegisterFormula(H.Formula_IrrigatedFarmFood, Formula_IrrigatedFarmFood.Evaluate,
readsChannels: []);
registry.RegisterFormula(H.Formula_TerracedFarmFood, Formula_TerracedFarmFood.Evaluate,
readsChannels: []);
registry.RegisterFormulaContribution(H.Formula_FarmFood, H.FoodOutput);
registry.RegisterFormulaContribution(H.Formula_IrrigatedFarmFood, H.FoodOutput);
registry.RegisterFormulaContribution(H.Formula_TerracedFarmFood, H.FoodOutput);
// Template registration block
registry.RegisterTemplate(Farm.Id, Farm.Entry);
registry.RegisterTemplate(IrrigatedFarm.Id, IrrigatedFarm.Entry);
registry.RegisterTemplate(TerracedFarm.Id, TerracedFarm.Entry);
(examples/valenar/Generated/SecsModule.cs, lines 35-75 — reordered here for proximity; the real file groups by kind)
What the compiler had to do
From the 44-line .secs source, the compiler emitted:
| Artifact | File | Lines |
|---|---|---|
| Farm template class | Generated/Templates/Buildings/District/Farm.cs | 43 |
| IrrigatedFarm template class | Generated/Templates/Buildings/IrrigatedFarm.cs | ~35 (similar shape) |
| TerracedFarm template class | Generated/Templates/Buildings/TerracedFarm.cs | ~38 (similar shape, extra stone cost) |
| Farm formula | Generated/Formulas/Formula_FarmFood.cs | 25 |
| IrrigatedFarm formula | Generated/Formulas/Formula_IrrigatedFarmFood.cs | ~25 |
| TerracedFarm formula | Generated/Formulas/Formula_TerracedFarmFood.cs | ~25 |
Registration lines in SecsModule.cs | 3× RegisterTemplate, 3× RegisterFormula, 3× RegisterFormulaContribution | 9 |
Hash entries in Generated/Hashes.cs | H.Farm, H.IrrigatedFarm, H.TerracedFarm, H.Formula_FarmFood, H.Formula_IrrigatedFarmFood, H.Formula_TerracedFarmFood, H.FoodOutput, H.GoldCost, H.WoodCost, H.StoneCost | 10 |
One source file → seven generated files plus nine module registrations plus up to ten new hashes. The per-template lowering is mechanical; the cross-file emission (formulas in Generated/Formulas/, registration in Generated/SecsModule.cs, hashes in Generated/Hashes.cs) is what makes this doc's contract non-trivial.
Under the own-root channel-source contract: Farm is in-scope
Earlier drafts used a district scope that no longer exists in Valenar. Farm is now a location-rooted Building template, and FoodOutput is a location channel (source = location.FoodOutput), so channel int FoodOutput { return ... } is in-scope for template<Building> Farm. Cross-scope Settlement effects still use modifiers attached to settlement; the correction is about the target scope, not about inventing a second contribution primitive.
Lowering summary (per-feature cheat sheet)
| SECS construct | TemplateEntry init | Static method emitted | Cross-file emission |
|---|---|---|---|
template<C> Name { } (shell) | TemplateId, Name, ContractId, RootScopeId, Activate | Activate(...) (always) | 1 hash entry H.Name |
name = "X"; | Name = "X" | — | — |
int F = 10; / const int F = 10; at template top level | compile error | — | proposed SECS0211: template-level scalar data must use field or channel; bare C# locals/constants are only valid inside executable bodies unless a future private-helper syntax is specified |
field int F = 10; (scalar template field assignment) | GetFieldInt = GetField | GetField(ulong fieldId) switch arm H.F => 10 | 1 hash entry H.F; global field metadata comes from top-level field declaration with ValueType = SecsTypeRef.Int |
field FeaturePlacementProfile Placement = new(...); (structured template field assignment) | structured field registration/accessor | concrete generated accessor such as GetPlacement() returning FeaturePlacementProfile | 1 hash entry H.Placement; global field metadata uses SecsTypeRef.Record(H.FeaturePlacementProfile) |
channel int S = 5; (per-instance flat, scope-match required) | — | line in Activate: cmds.RegisterChannelSource(..., H.S, 5) registers an intrinsic source on scope.Root; compile error SECS0201 if S declared at a scope ≠ template's root_scope | 1 hash entry H.S |
channel int S { return expr; } (per-instance derived, scope-match required) | — | line in Activate: cmds.RegisterDynamicChannelSource(..., H.S, H.Formula_NameS) registers a dynamic intrinsic source on scope.Root; compile error SECS0201 if S declared at a scope ≠ template's root_scope | 1 Formulas/Formula_NameS.cs, 2 lines in SecsModule.cs |
query bool CanBuild() { ... } where contract C declares query bool CanBuild(); | query registration keyed by H.Contract_Building_CanBuild_NoArgs_Bool | CanBuild(ScopeFrame scope, ISecsHostReads host) returns bool; creation-time query frame has root, no this | query id on ContractDeclaration; return type SecsTypeRef.Bool on ContractMethodDeclaration |
query FeaturePlacementResult EvaluatePlacement(Location candidate, FeaturePlacementInput input) { ... } | query registration keyed by full owner+signature id | concrete typed method returns FeaturePlacementResult; adapter receives typed candidate and input | return type SecsTypeRef.Record(H.FeaturePlacementResult); parameters [SecsTypeRef.EntityInScope(H.Location), SecsTypeRef.Record(H.FeaturePlacementInput)] |
method void OnBuilt() { non-empty } | Methods[H.Contract_Building_OnBuilt_NoArgs_Void] = TemplateCommand.NoArgs(...) | OnBuilt(ITemplateCommandContext context, in SecsNoArgs args) or a typed args struct; source frame has this / owner plus root through context.Scope | methods remain command-producing and SecsTypeRef.Void |
method void OnBuilt() { } (empty) | — (elided) | — (elided) | — |
method void OnDestroyed() { non-empty } where contract C declares method void OnDestroyed(); | Methods[H.Contract_Building_OnDestroyed_NoArgs_Void] = TemplateCommand.NoArgs(...) | OnDestroyed(ITemplateCommandContext context, in SecsNoArgs args) or a typed args struct; existing-instance frame has this / owner plus root through context.Scope | method id on ContractDeclaration; automatic destruction requires a deactivation OnDestroyed; lifecycle binding |
reads { S1 } | — (extracted to formula level) | — | readsChannels: [H.S1] on matching RegisterFormula |
add_modifier M; inside a method (same-scope attach) | — | context.Commands.AddModifier(..., H.M, 0, -1); context.FlushCommands(); | — (M's declaration lives elsewhere) |
scope.add_modifier M; inside OnBuilt (cross-scope attach) | — | var t = context.Host.WalkScope(context.Scope.Root, H.<Scope>); context.Commands.AddModifier(context.Scope.Owner, t, H.M, 0, -1); context.FlushCommands(); | — (M is a stackable modifier declared separately) |
add_modifier M when COND; | — | context.Commands.AddModifier(..., H.M, H.Trigger_XXX, -1); context.FlushCommands(); | 1 Triggers/Trigger_XXX.cs (see doc 03) |
create_entity T; / create_entity T with { F = V; }; inside a method | — | context.CreateEntity(new TemplateId(H.T), ...) | T must be a registered template; with values seed root-scope fields before activation |
replaces Source; / replaces Source { requires Q; } | — | optional query Q(ScopeFrame scope, ISecsHostReads) | transition row keyed by (H.Source, H.Name) with optional query id |
public static readonly TagId A = TagId.Create("ns:tag/a"); in the game's Tags class | optional generated vocabulary mirror | — | TagId.Value is FNV-1a-64 of the canonical string; any generated H.Tag_A mirror must equal that value |
tag Name; (top-level) | compile error | — | not SECS syntax; declare Tags.Name as plain C# instead |
tags = A, B; (template body) | Tags = new ReadOnlyMemory<ulong>(new ulong[] { H.Tag_A, H.Tag_B }) | — | A and B must resolve to TagId constants in the game's Tags class |
contract C { registry_only; ... } (contract qualifier; specified/future current gap) | intended per template: Activate omitted, Methods = [] | — | intended ContractDeclaration.IsRegistryOnly = true; current stand-ins use no-op activation until runtime/compiler support lands |
(no .secs source for scope S) | per Bare template | BareS with empty Activate | RegisterDefaultTemplate(H.S, BareS.Id) in SecsModule.cs |
Rules that apply to every emission regardless of feature:
- Hash every SECS named thing. Every template name, template field name, channel name, lifecycle method name, trigger name, formula name, modifier name, activity name, and declared scope/contract name gets an entry in
Generated/Hashes.cs. Tag identities are the exception: their source of authority is a plain C#TagId.Create("ns:tag/name")constant in the game'sTagsclass. Generated code may mirror those typed ids asH.Tag_Nameulong constants, but the mirrored value must be theTagId.Valuehash of the canonical identity string, not a hash ofTag_Nameand not the result of atag Name;declaration. Hashes are FNV-1a-64, computed identically tosecs-roslyn's hash function. Collisions are caught at registration / union merge. - Namespace from path.
Content/<bucket>/*.secs→Valenar.Generated.Templates.<Bucket>(PascalCase). Bare templates map to.Templates.Bare. - One class per template, one file per class. Multi-template
.secssources produce multiple generated files — do not try to put two templates in one C# file. Entryis alwayspublic static readonly. Do not emitreadonlyfields orgetter-onlyproperties that compute the entry lazily. The engine's registration call takes the value directly; laziness here is wasted.TemplateEntryinit field order is canonical. See Barracks emission for the order. This matters for diffs, not for correctness — the engine reads init fields by name.- No reflection anywhere. The compiler emits static, AOT-friendly code. No
Type.GetType, noActivator.CreateInstance, no attribute scanning at startup. Everything is wired by the compiler at emit time. - No allocations at registration time beyond the
TemplateEntryand itsMethodsdictionary. The delegate wiring is method groups, not lambdas. TheGetFieldbody is a switch expression, not a dictionary. - Contract-method extension is closed under mods. A mod operation on a template may only add void methods (
OnBuilt,OnDestroyed,OnDayTick, etc.) that the template's contract already declares in its method list. An operation that addsmethod void OnRaided() { ... }to aFarmwhoseBuildingcontract does not listOnRaidedis a compile error:SECS0209: method 'OnRaided' is not declared on contract 'Building'; add the method to the contract first or use a new contract. To introduce a new automatic lifecycle hook for an existing template family, the mod must define its own contract and its own template hierarchy under that contract — contract method-list extension and lifecycle-binding extension are not mod-operation slots. This applies identically to base and mod authors. Cross-reference06-overrides-and-modding.md § "What's NOT overridable"for the full closed-surface enumeration.
Mod-introduced cross-scope channel bugs
The 2026-04-26 content sweep removed the base-game examples that violated the own-root channel-source contract: location-rooted Building templates no longer declare settlement-scoped channel entries directly. They attach modifiers to settlement instead (HouseMoralePresence, WatchtowerDefense, BarracksGarrison, etc.).
Mod-authored templates can still produce the same bug: a mod's template<Building> ModFarm { channel int Production = 10; } where Production is settlement-scoped is invalid because the template root_scope is Location. SECS0201 is mod-aware by construction — the compiler check runs at the AST level on every template body in the merged source set, so mod templates and base templates share one validation path. Mods that need cross-scope effects must follow the same add_modifier shape documented in this doc's "Cross-scope effects: modifier attachments" section. Cross-reference 06-overrides-and-modding.md § "Diagnostics fired across source sets" for the merged-source-set firing rule.
Storehouse is intentionally not converted to a modifier on Food: the old channel int Food = 100/250/500 rows were removed because Food is an Accumulative stockpile channel and modifiers do not apply to accumulative resources. Storage capacity is a separate channel (FoodCapacity, GoldCapacity, etc.) as documented in 01-world-shape.md § "Resource amount, capacity, and flow".
Current invariant: template-body channels stay on the activation root
The live rule is unchanged: a template-body channel declaration or intrinsic resolve(X) use must target a channel on the template's own activation root. Cross-scope effects still use named modifier attachments, and undeclared channel names still fall under SECS0104 in 01-world-shape.md.
Valenar's earlier stand-in bugs are resolved and kept only as traceability in docs/design/FUTURE_WORK.md § 3.1 and the historical audits. The live contract here is the invariant, not the old bug list.
Cross-references
- Formula bodies and
resolve(...)lowering — how@Location.Fertilitybecomeshost.WalkScope(owner, H.Location)+host.ReadInt(location, H.Location, H.Fertility), and how dependency lists (readsChannels) are extracted from the expression tree — see03-channels-and-modifiers.md. - Modifier declarations referenced by templates —
add_modifier BountifulHarvest,modifier DungeonAuraOfDread { Morale -= 5; }— declaration syntax, lowering, and registration — see03-channels-and-modifiers.md. - Method body syntax inside contract methods —
resolve(...),scope.X,add_modifier ... when ... for ...,increment, host method calls, and how every expression lowers throughISecsHostReads/ITemplateCommandContext— see05-expressions.md. - Mod operations — how a mod can
injectorreplacetemplate<Building> Farm { ... }and what the compiler emits for merge resolution — see06-overrides-and-modding.md. - Entity creation statements —
create_entity T;, initial field blocks, ambient scope parenting, and why creation blocks do not contain nested rules — see05-expressions.md § "create_entity — runtime entity creation". - Entity lifecycle (activation / destruction / transition-replace) — the engine-side handler that invokes
Activate, contractLifecycleBindings,Methods[...], and future template transition edges — see the current runtime surfaces inTemplateActivator,TemplateEntry, and the related engine lifecycle helpers; a dedicated runtime-lifecycle doc remains future work. - Tag propagation and aggregate filters —
propagates_to children where has_tag Xmodifier propagation andChildren.Where(has_tag X)aggregate syntax — see08-collections-and-propagation.md § "Part 3 (Tags)". - Recipe system and
registry_onlycontracts — full recipe-lookup spec,RecipeEntrylookup patterns, and the engine surface for metadata-only templates — see08-collections-and-propagation.md § "Part 7 (Recipe System)".