Skip to main content

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 via add_modifier. See 01-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 generated SecsTypeRef metadata; generated query implementations return their declared concrete C# type (bool, FeaturePlacementResult, etc.), and generated command methods use typed argument structs such as SecsNoArgs. SecsValue is reserved for command payload storage or explicitly erased dynamic/tooling adapters. See 07-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.mdscope, contract, channel, field, and type declarations. Templates bind to a scope via their contract's root_scope; a template with template<Building> only makes sense because contract Building { root_scope Location; ... } has already been declared.
  • 07-structured-template-data-and-callables.md — C# source type binding, generated SecsTypeRef metadata, 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. Includes Tags: ReadOnlyMemory<ulong> (set-membership classification), StructuredFields, query/method dictionaries, and Activate. See 08-collections-and-propagation.md for the tag surface.
  • src/SECS.Engine/Instances/TemplateActivator.cs — activates templates and dispatches contract-declared query/method delegates. registry_only activation 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 of IsRegistryOnly: bool for 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 shared TemplateEntry. Source-level functions receive world context through a contract scope frame; the generated static delegates receive EntityHandle values 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 in Generated/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 on TemplateEntry for debugging and for UI fallbacks.
  • ContractId = H.BuildingContract and RootScopeId = H.Location are both lookups the compiler can do at emit time: the contract name came from template<Building>; the root scope came from the contract declaration's root_scope. Both are required init fields on TemplateEntry, so the compiler must always emit them.
  • Activate = Activate is also required — even a template with no body (see Bare templates below) emits an empty Activate method and wires it up.
  • Contract-declared queries are registered by id. The Farm above implements CanBuild, and contract Building declares query bool CanBuild();, so the template registers [H.Contract_Building_CanBuild_NoArgs_Bool] = CanBuild. CanBuild is contract data, not a compiler keyword.
  • Namespace convention: Valenar.Generated.Templates.{Bucket} where Bucket is the subdirectory the .secs file lives in (district_buildingsBuildings, featuresFeatures, charactersCharacters). Bare templates go under Bare. The mapping is not inferred from the .secs file contents; it comes from the file path.

Notes:

  • Should multi-template .secs files (like farm.secs declaring Farm, IrrigatedFarm, TerracedFarm) produce one C# file per template or one C# file per .secs file? 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 root and declared walks from root; they do not have this or owner.
  • Lifecycle methods (OnBuilt, OnDestroyed, OnDayTick, etc.) run for an existing instance. They have this / owner for that instance plus root for 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 / owner name the entity being transitioned, while root remains 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:

  • field gives template-definition data its own explicit surface. GoldCost can 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 / GetFieldBool accessor shape. A structured template field lowers to generated concrete accessors such as GetPlacement() plus TemplateEntry.StructuredFields[H.Placement] and TemplateFieldDeclaration.ValueType = SecsTypeRef.Record(H.FeaturePlacementProfile). Known generated call sites should receive concrete C# values; dynamic host/tooling reads validate against SecsTypeRef.
  • field values are readonly for the template definition. Every Farm shares base GoldCost = 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 generated GetField switch or structured accessor. A discount can make "Farm in settlement X costs 8 gold" while the Farm template still reports base GoldCost = 10. Additive/multiplicative field modifiers are numeric-scalar only; structured fields are whole-value HardOverride only, 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 field declaration owns display metadata, localization (field_goldcost, field_goldcost_desc), and the SecsTypeRef. 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 write field int GoldCost = 10;. This keeps ordinary C# local variable syntax (int x = ...) reserved for executable bodies.
  • const is a C# helper concept, not a template-field marker. Phase 1 should reject top-level const 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 emit H.*, localization, template-field metadata, or mod-operation slots.

Notes:

  • Whether scalar field assignments 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 to template, scope, or contract. Declaring tag identifiers is ordinary C# in the game's Tags class.
  • 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 generated H.Tag_* mirror must equal the corresponding Tags.X.Value; it is a convenience constant, not a separate declaration source.
  • tags = A, B; is a template-body slot assignment — it is not a field assignment and not a channel source. It is static classification metadata available before any entity exists. All template types accept tags = ... uniformly (Building, Feature, Character, Province, etc.). The same assignment form is also used in modifier bodies for the modifier_tags slot; this section only shows the template case.
  • Short names in tags = A, B; resolve against the game's Tags class. tags = Military; and tags = Tags.Military; are equivalent when unambiguous.
  • TemplateEntry.Tags is ReadOnlyMemory<ulong> (empty when the template declares no tags). The values are typed tag identities: the game's TagId constants are the vocabulary source, generated H.Tag_* mirrors are convenience constants, and generated registration emits the collected tag catalog through SecsRegistry.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)) — see 08-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_entity is 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 an ActivityRunContext).
  • Bare create_entity T; in a template method parents to context.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 same CreateEntity API and stores the returned EntityHandle in child.
  • 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 of create_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_entity path 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:

  • Activate is 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. owner is the template instance's entity. scope.Root is 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 use cmds.AddModifier.
  • Literal numeric value only. If the right-hand side looks like 5 + 3 the compiler folds it to 8 at 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 a Building template (root_scope Location) declares channel int Morale = 5; but Morale is on Settlement scope, that is SECS0201. The correct expression for "this Building raises settlement Morale" is a stackable modifier attached to settlement — see the Cross-scope effects section below.
  • The per-call inline // comment you see in Farm.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 that Formula_* files use.

Notes:

  • channel float per-instance flat channels are not yet exercised. When they appear, the emit is the same shape with a float value and (likely) a separate overload on CommandBuffer.
  • 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 a channel int FoodOutput on 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's template<Building> MegaFarm with a per-instance derived channel int FoodOutput { ... } produces Formula_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. Once SECS0201 (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 becomes Formula_{ModifierName}{ChannelName} by extension. See 06-overrides-and-modding.md § "Slot schema" for the full slot enumeration covering both base and mod-emitted formulas.
  • The template's Activate lowers to RegisterDynamicChannelSource(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, calls Evaluate, and adds the result in the Contributed phase-1 gather. The command name is intentionally runtime-facing and is not a .secs author 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 own Formula_*FoodProduction.cs.
  • SecsModule.cs grows two lines per formula: RegisterFormula(id, delegate, readsChannels: [...]) and RegisterFormulaContribution(formulaId, channelId). The dependency array (readsChannels) is built from the expression's resolve calls and is critical for cycle detection at registration time (see registry.ValidateDependencies()).
  • The inline // .secs: ... comment above the RegisterDynamicChannelSource call 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 Activate when 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 on settlement, not location.

Notes:

  • How does the compiler decide between int and float formula return types? By the declared channel type. channel floatFormula_*.Evaluate returns float; channel int → returns int. The engine's RegisterFormula overload is selected accordingly. Only int formulas exist today.
  • See 03-channels-and-modifiers.md for how resolve(...) and scope.resolve(...) lower inside the Evaluate body, and how readsChannels is 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 HouseMoralePresence is 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 = stackable means re-applying the modifier adds another copy to the pile instead of refreshing or rejecting. N Houses contribute N stacks of HouseMoralePresence, each worth +5 Morale. 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 returning bool, FeaturePlacementResult, CostPreview, and so on. Dynamic registry adapters may erase that value for host/tooling calls, but the adapter validates against ContractMethodDeclaration.ReturnType: SecsTypeRef; the source/runtime contract is not SecsValueKind. A creation-time caller constructs the frame with ScopeFrame.ForCreation(root), so the source frame binds @root = scope.Root; @this and @owner are not available. The compiler rewrites the method body so every scope walk, channel resolve, template-value read, and field read goes through host.
  • Query source signatures do not accept world-context parameters. A query body reads @root and declared walks from the root frame (@Location, @Settlement, etc.); it does not receive Location location, Settlement settlement, Owner owner, or equivalent entity handles from the caller.
  • Read-only scope methods get desugared into typed ISecsHostReads calls or typed dynamic adapters. @Location.BuildingCount<Watchtower>() becomes a location-scope host query returning int, with the template id supplied as a TemplateId helper argument. This is because BuildingCount is declared on the Location scope as query int BuildingCount(TemplateId templateId) — a read-only host-implemented method on a scope. method void scope methods such as Valenar's DrainStamina, GainXp, AdvanceLead, and Reveal require a command context and lower through ISecsHostCommands; they are not legal inside CanBuild or EvaluatePlacement.
  • Template-value reads with context go through the host read boundary in generated queries. template_field(Watchtower, GoldCost, context(@Settlement, @Location)) lowers to host.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 (CanBuild on Building, CanCast on Spell, CanPlace on Machine). The compiler knows which template methods are queries by checking the contract declaration's query list, not by hard-coding CanBuild. Any template void method 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.
  • CanBuild is 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 no walks_to edge exists, the compiler emits SECS0111; 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.This is the existing template instance, context.Scope.Root is the contract's root scope, reads go through context.Host, writes go through context.Commands, and source parameters are validated against ContractMethodDeclaration.ParameterTypes: SecsTypeRef[]. Known generated call sites use typed argument structs such as SecsNoArgs; erased dynamic/tooling adapters may translate into those typed arguments after validating SecsTypeRef, 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-level method void OnBuilt(Location location, Settlement settlement, Owner owner) form.
  • The dictionary key is the contract owner + full signature id. contract Building { method void OnBuilt(); } hashes contract:Building.OnBuilt():void and emits H.Contract_Building_OnBuilt_NoArgs_Void; every Building template that implements OnBuilt() uses that same key. A different contract's OnBuilt() would receive a different id.
  • The contract's callable signature metadata is emitted separately as ContractMethodDeclaration[]. ContractDeclaration.QueryMethodIds and ContractDeclaration.MethodIds remain the runtime dispatch/lifecycle lists; ContractMethodDeclaration carries the compiler-facing name, return SecsTypeRef, parameter SecsTypeRefs, 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. OnBuilt is Valenar's name for the Building contract's activation effect. A different game can declare method void OnEquipped(); activation OnEquipped; on an Equipment contract, or method 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() { } and method void OnInteract() { } as placeholders. Neither appears in the generated Methods dictionary: only OnSpawned and OnCleared (the ones with real bodies) are registered. Same story for Granary's method void OnDestroyed() { } and method void OnDayTick() { } — the generated Granary.cs Methods only lists H.Contract_Building_OnBuilt_NoArgs_Void (see Generated/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 no when or for lowers to context.Commands.AddModifier(context.Scope.Owner, context.Scope.Root, H.X, triggerId: 0, duration: -1) followed by context.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 in 03-channels-and-modifiers.md.
  • The Methods dictionary is initialised eagerly at class-construction time. Because TemplateEntry.Methods is declared IReadOnlyDictionary<ulong, ITemplateCommand>, the hand-written emission uses new Dictionary<ulong, ITemplateCommand>() { ... } with typed wrappers such as TemplateCommand.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 --emit and 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-identical Methods = { ... } line. See 00-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 to ContractLifecycleIds.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 name OnBuilt.
  • The same target rule applies to future or less-used lifecycle slots. If an Equipment contract binds deactivation OnUnequipped;, the runtime resolves ContractLifecycleIds.Deactivation -> H.Contract_Equipment_OnUnequipped_NoArgs_Void and invokes that method through TemplateEntry.Methods. There is no separate template delegate path for deactivation.

Notes:

  • remove_modifier inside 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: owner is the lifetime source and target is the effect anchor. A mod overriding an OnBuilt attach site cannot leak the old binding by forgetting a paired OnDestroyed, because ordinary cleanup is keyed by the runtime binding owner rather than a second source method.
  • Which lifecycle slots beyond activation and deactivation should 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_only is 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 is ContractDeclaration.IsRegistryOnly = true plus activation/create rejection. That is still a future end-to-end gap today.
  • The target compiler/runtime shape omits TemplateEntry.Activate for registry-only templates. Current stand-ins still wire a no-op activation delegate because the live registry validation path requires Activate != null.
  • registry_only is about activation suppression, not registry accessor eligibility. registry[id] may read metadata for any registered template; once the gap is closed, create_entity / CreateEntity must 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 calls Activate(H.IronSword, ...). StructuredFields and all scalar field accessors are fully populated.
  • registry_only contracts are intended not to declare lifecycle bindings, queries, or methods. The future compiler/runtime handoff reserves SECS0213 for that rule once the contract is implemented end to end.
  • The root_scope of a registry_only contract must be None (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_only templates participate in the mod-operation system normally. inject template IronSword { field GoldCost = 12; } rewrites only the scalar field value; IsRegistryOnly is 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_only templates 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). No Methods. No GetFieldInt. An empty Activate body.
  • The compiler needs the contract → scope mapping to emit these. BareRegion's contract is H.RegionContract and its root scope is H.Region — both come from the contract declaration for Region. If a scope has no contract declared (like the ad-hoc host scope), no Bare is emitted.
  • BareLocation is the interesting outlier: even though it's "bare", it registers Slots = 4 as an intrinsic flat channel source (see Generated/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 register Slots = 4 unless 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.
  • BareCharacter is registered but is not the subject of RegisterDefaultTemplate(H.Character, BareCharacter.Id) alone — the module also registers MainCharacter separately. Characters use RegisterDefaultTemplate(H.Character, BareCharacter.Id) for host/runtime convenience, and the host explicitly passes MainCharacter.Id when creating the player (see Generated/SecsModule.cs, lines 107-110).
  • Namespace is Valenar.Generated.Templates.Bare. File name is Bare{Scope}.cs. Hash id is H.Bare{Scope} — one entry per bare template in Generated/Hashes.cs.
  • The compiler emits one Bare template even when a user .secs file already exists for the same scope. For example, characters/main_character.secs declares a BareCharacter explicitly (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 .secs templates 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 = 4 on 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 in 01-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 .secs source 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 .secs path twice. The header has it; inline comments do not.

Notes:

  • Should the compiler embed source-range metadata (line/col) in a sidecar .map file? 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 readsChannels from the expression tree; the reads { ... } block is a manual override only". So the reads { FoodOutput } block above is redundant with what the compiler would infer by walking resolve(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 reads list be surfaced as metadata on TemplateEntry for 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-formula readsChannels, and the per-formula form is what the engine actually uses. No decision made; emit-nothing is the current convention.
  • If reads is 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 UpgradeTarget or field 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 discount GoldCost; they cannot add or remove outgoing transition edges.
  • Branching is natural. IrrigatedFarm and TerracedFarm can both say replaces 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. CanIrrigateFarm answers 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 into IrrigatedFarm.
  • 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 shorthand replaces Source; is already used in Valenar's hand-written .secs content.
  • Whether transition declarations should additionally emit query method ids for tooltip/provenance. Current runtime table stores the delegate; diagnostics/provenance may need a QueryMethodId later.
  • 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 the Name init field on TemplateEntry. The compiler treats name (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";, the Name init on TemplateEntry uses that string. If a template does not declare name = ...;, the compiler falls back to the C# identifier (Name = "Farm" for template<Building> Farm). Bare templates follow the same rule — BareRegion has no name = ... and gets Name = "Region" by default (see Generated/Templates/Bare/BareRegion.cs, line 13: Name = "Region", derived from stripping the Bare prefix).
  • Activate is emitted even for identity-only templates. The body is empty but the delegate wire-up is not optional — TemplateEntry.Activate is a required init property on the engine side.
  • The namespace picks up .Characters (from the characters/ content sub-tree). Same mechanical mapping as Buildings/Features.
  • The Name field on TemplateEntry is not localizable. Localization keys are separate and live in the localization YAML. Name is 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 name accept an expression (e.g. name = "Hero " + Level;)? No — the init field is a plain string on TemplateEntry and 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 TemplateEntry rather 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 contract LifecycleBindings plus Methods, not by special per-lifecycle fields. This keeps diffs clean.
  • The static method declarations follow in the same order: Activate, then OnBuilt (lifecycle), then CanBuild (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_DefenseGte50 is a compiled trigger — a separate concern that lives in Generated/Triggers/. The modifier command @Settlement.add_modifier Fortified when @Settlement.resolve(Defense) >= 50 lowers to a WalkScope(..., H.Settlement), a trigger entry (Trigger_DefenseGte50), and the AddModifier call referencing it. Trigger lowering is covered in 03-channels-and-modifiers.md.
  • The resource costs are field int declarations, so they route through GetFieldInt = GetField; they are not registered as channel sources in Activate.

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:

ArtifactFileLines
Farm template classGenerated/Templates/Buildings/District/Farm.cs43
IrrigatedFarm template classGenerated/Templates/Buildings/IrrigatedFarm.cs~35 (similar shape)
TerracedFarm template classGenerated/Templates/Buildings/TerracedFarm.cs~38 (similar shape, extra stone cost)
Farm formulaGenerated/Formulas/Formula_FarmFood.cs25
IrrigatedFarm formulaGenerated/Formulas/Formula_IrrigatedFarmFood.cs~25
TerracedFarm formulaGenerated/Formulas/Formula_TerracedFarmFood.cs~25
Registration lines in SecsModule.csRegisterTemplate, 3× RegisterFormula, 3× RegisterFormulaContribution9
Hash entries in Generated/Hashes.csH.Farm, H.IrrigatedFarm, H.TerracedFarm, H.Formula_FarmFood, H.Formula_IrrigatedFarmFood, H.Formula_TerracedFarmFood, H.FoodOutput, H.GoldCost, H.WoodCost, H.StoneCost10

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 constructTemplateEntry initStatic method emittedCross-file emission
template<C> Name { } (shell)TemplateId, Name, ContractId, RootScopeId, ActivateActivate(...) (always)1 hash entry H.Name
name = "X";Name = "X"
int F = 10; / const int F = 10; at template top levelcompile errorproposed 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 = GetFieldGetField(ulong fieldId) switch arm H.F => 101 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/accessorconcrete generated accessor such as GetPlacement() returning FeaturePlacementProfile1 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_scope1 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_scope1 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_BoolCanBuild(ScopeFrame scope, ISecsHostReads host) returns bool; creation-time query frame has root, no thisquery id on ContractDeclaration; return type SecsTypeRef.Bool on ContractMethodDeclaration
query FeaturePlacementResult EvaluatePlacement(Location candidate, FeaturePlacementInput input) { ... }query registration keyed by full owner+signature idconcrete typed method returns FeaturePlacementResult; adapter receives typed candidate and inputreturn 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.Scopemethods 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.Scopemethod 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 methodcontext.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 classoptional generated vocabulary mirrorTagId.Value is FNV-1a-64 of the canonical string; any generated H.Tag_A mirror must equal that value
tag Name; (top-level)compile errornot 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 templateBareS with empty ActivateRegisterDefaultTemplate(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's Tags class. Generated code may mirror those typed ids as H.Tag_Name ulong constants, but the mirrored value must be the TagId.Value hash of the canonical identity string, not a hash of Tag_Name and not the result of a tag Name; declaration. Hashes are FNV-1a-64, computed identically to secs-roslyn's hash function. Collisions are caught at registration / union merge.
  • Namespace from path. Content/<bucket>/*.secsValenar.Generated.Templates.<Bucket> (PascalCase). Bare templates map to .Templates.Bare.
  • One class per template, one file per class. Multi-template .secs sources produce multiple generated files — do not try to put two templates in one C# file.
  • Entry is always public static readonly. Do not emit readonly fields or getter-only properties that compute the entry lazily. The engine's registration call takes the value directly; laziness here is wasted.
  • TemplateEntry init 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, no Activator.CreateInstance, no attribute scanning at startup. Everything is wired by the compiler at emit time.
  • No allocations at registration time beyond the TemplateEntry and its Methods dictionary. The delegate wiring is method groups, not lambdas. The GetField body 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 adds method void OnRaided() { ... } to a Farm whose Building contract does not list OnRaided is 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-reference 06-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.Fertility becomes host.WalkScope(owner, H.Location) + host.ReadInt(location, H.Location, H.Fertility), and how dependency lists (readsChannels) are extracted from the expression tree — see 03-channels-and-modifiers.md.
  • Modifier declarations referenced by templatesadd_modifier BountifulHarvest, modifier DungeonAuraOfDread { Morale -= 5; } — declaration syntax, lowering, and registration — see 03-channels-and-modifiers.md.
  • Method body syntax inside contract methodsresolve(...), scope.X, add_modifier ... when ... for ..., increment, host method calls, and how every expression lowers through ISecsHostReads/ITemplateCommandContext — see 05-expressions.md.
  • Mod operations — how a mod can inject or replace template<Building> Farm { ... } and what the compiler emits for merge resolution — see 06-overrides-and-modding.md.
  • Entity creation statementscreate_entity T;, initial field blocks, ambient scope parenting, and why creation blocks do not contain nested rules — see 05-expressions.md § "create_entity — runtime entity creation".
  • Entity lifecycle (activation / destruction / transition-replace) — the engine-side handler that invokes Activate, contract LifecycleBindings, Methods[...], and future template transition edges — see the current runtime surfaces in TemplateActivator, TemplateEntry, and the related engine lifecycle helpers; a dedicated runtime-lifecycle doc remains future work.
  • Tag propagation and aggregate filterspropagates_to children where has_tag X modifier propagation and Children.Where(has_tag X) aggregate syntax — see 08-collections-and-propagation.md § "Part 3 (Tags)".
  • Recipe system and registry_only contracts — full recipe-lookup spec, RecipeEntry lookup patterns, and the engine surface for metadata-only templates — see 08-collections-and-propagation.md § "Part 7 (Recipe System)".