Skip to main content

05 — Expressions

Every construct in docs 01–04 draws a box: a scope, a contract, a template, a modifier, a system, an event, an activity. This document is about what goes inside those boxes — the inner syntax that appears in template lifecycle method bodies, in system { method void Execute() { ... } } blocks, in event methods such as Condition, Execute, BuildOptions, and option handlers, in class-based activity lifecycle methods such as CanStart, OnStart, and OnUpdate, and in formula return expressions. On-actions are metadata-only in committed source syntax.

The surface looks like ordinary C#: var x = y.resolve(Z); if (x > 0) y.increment(Z, 1);. The lowering is not. Every scope.field is a host.WalkScope(...) + typed host.ReadX(...). Every .resolve(Channel) is a typed ctx.ChannelResolver.ResolveXFast(entity, H.Channel). Every increment, add_modifier, create_entity, fire, and save_scope_as is a call into ITemplateCommandContext, TickContext, or a CommandBuffer extension. Every foreach is a plain foreach (var x in ctx.InstanceStore.AllByContract(...)) or host.GetChildren(...) loop — plus a per-iteration flush idiom that does not appear in the SECS source. This doc is the lowering table.

The dominant target types:

  • ISecsHostReads (src/SECS.Abstractions/Interfaces/ISecsHostReads.cs) — the read surface. Scope walks, raw field reads, channel resolution re-entries, and read-only scope methods.
  • ISecsHostCommands — the target command bridge for host-exposed void scope methods. This is the command-producing sibling of ISecsHostReads: generated code can use it only when ITemplateCommandContext, TickContext, or an activity command context is in scope.
  • TemplateValueResolver (src/SECS.Engine/Resolution/TemplateValueResolver.cs) — reads template base fields and applies active field modifier effects in a context.
  • CommandBuffer (src/SECS.Abstractions/Commands/CommandBuffer.cs) and its extensions (CommandBufferExtensions.cs) — the write surface. Channel sources, modifier bindings, scope-field increments, tag-based removals.
  • TickContext (src/SECS.Engine/Pipeline/TickContext.cs) — the per-tick cockpit. Holds Host, ChannelResolver, InstanceStore, Events, Commands, CommandProcessor, Random, saved scopes, and entity creation helpers.
  • ActivityRun / ActivityRunContext (see 04-behavior.md) — the active execution context for class-based activities. Carries Actor, optional Target, elapsed/delta/progress/status, tick/host/command access through the current run context, and generic lifecycle transitions such as Complete, Stop, Cancel, and Fail.
  • C# source types plus generated SecsTypeRef metadata (see 07-structured-template-data-and-callables.md) — the type contract for template fields, scope methods, contract query/method parameters, and query return values. Generated call sites use concrete C# types and typed argument structs; SecsValue is reserved for command payload storage or explicitly erased dynamic/tooling adapters.

Prerequisites

  • 00-overview.md — doc skeleton, SECS expansion, lowering-first stance.
  • 01-world-shape.md — scope declarations (what settlement refers to), scope field declarations (what .Fertility lowers to), contract declarations.
  • 02-templates.md — template method bodies are the first site where these expressions appear.
  • 03-channels-and-modifiers.md — the modifiers referenced by add_modifier are declared here; the triggers referenced by when / while are declared here.
  • 04-behavior.md — systems, events, on-actions, and class-based activities — the behavior bodies that contain most of the expressions in this doc.

Scope sigils

A scope sigil is an name prefix on a dotted expression. Every sigil resolves to an EntityHandle that the compiler then uses as the target of either a field read, a channel resolution, an increment, an add_modifier, or an iteration source. Sigils are always starting points — the identifier after the dot is the actual field / channel / method / collection being accessed.

There are two families: built-in sigils (five names, baked into the compiler) and game-declared sigils (one per scope declaration in the domain-owned Content/**/scopes.secs files).

Built-in sigils

What: Five sigils the compiler always understands regardless of which scope vocabulary the game declares: @this, @root, @prev, @owner, @host. @root and declared scope sigils are the normal way contract/template functions receive world data. @this / @owner exist only when an entity instance already exists; creation-time query calls run before instance creation and therefore do not bind them.

SECS (each sigil's meaning, one line):

@this // the current existing entity — unavailable in creation-time queries; rebinds inside foreach
@root // the contract root_scope entity for this scope frame — never changes
@prev // one level up the foreach stack — same as @root at depth 1
@owner // the template instance that registered the current channel source / binding; unavailable pre-creation
@host // escape hatch for host-level commands (creation / destroy / bridge calls)

Compiles to: Generated contract/template/event delegates receive a ScopeFrame at the method boundary. Sigils are compile-time rewrites to frame properties or locals; foreach nesting may use scope.WithCurrent(...) or ordinary locals rather than dynamic lookup.

// Creation-time query: no template instance exists yet.
public static bool CanBuild(ScopeFrame scope, ISecsHostReads host, in SecsNoArgs args)
{
// @root -> scope.Root
// @Settlement -> host.WalkScope(scope.Root, H.Settlement)
// @this -> compile error in this frame
// @owner -> compile error in this frame
}

// Lifecycle method / existing-instance query: an existing instance owns the frame.
public static void OnSpawned(ITemplateCommandContext context, in SecsNoArgs args)
{
// @this -> context.Scope.Owner (at top level for existing-instance template functions)
// @root -> context.Scope.Root (the contract root_scope entity)
// @prev -> context.Scope.Root (depth 1 — same as @root until foreach pushes a frame)
// @owner -> context.Scope.Owner (the template instance)
// @host -> NOT an entity — rewrites to a command into `context.Commands`
}

Source: template Activate / lifecycle shape in examples/valenar/Generated/Templates/Locations/Sites/Dungeon.cs:22-39. For a trigger delegate, the corresponding locals are the four EntityHandle parameters in Trigger_*.Evaluate(target, captured0, captured1, host): target is the binding target, captured0 is the first captured outer scope (normally @root), and captured1 is the second captured outer scope (normally @prev). See docs/design/03-channels-and-modifiers.md § "Trigger lowering" for the captured-root pattern.

Formula / trigger delegate note: Formula and trigger delegates are lower-level runtime callbacks, not source-callable contract functions. They keep the fixed (target, captured0, captured1, host) shape because channel resolution and modifier re-evaluation call them outside a normal method body. Source authors still write scope expressions (@Location.Fertility, @Settlement.resolve(Morale)); the compiler decides whether that expression can use target directly, must walk from target, or must capture @root / @prev into captured0 / captured1.

Why this shape: The scope frame is a bounded contract, not an open parameter list. A method body that needs settlement data writes @Settlement.Gold and relies on root_scope + walks_to, rather than accepting a Settlement settlement parameter. The scope stack itself is a static property of nesting depth. A given @prev has exactly one answer per source-code location — the compiler substitutes a frame property or local variable. No dynamic name lookup is allocated. For triggers that execute outside method scope (a while trigger re-checked every binding tick), the compiler bakes the needed entities into the two Captured0 / Captured1 slots on the ModifierBinding at add_modifier time.

Scope-frame semantics (committed rules):

  • @root is assigned from the contract's root_scope target and is immutable for the duration of the method / modifier binding.
  • @this is available only when there is an existing entity instance or an iteration variable. At the top level of lifecycle methods and existing-instance queries it is the existing owner entity; inside foreach it changes to the iteration variable. Creation-time queries have no @this.
  • @prev is one scope stack frame up. At depth 1 (top-level, no surrounding foreach), @prev == @root.
  • @owner is the template instance that registered this effect. Different from @root: a Market building activated on Province 42 has @owner = Market instance entity, @root = Province 42.
  • @host is not an entity — it is a namespace for commands that bypass the channel pipeline (scope-field increments, creation/destroy). @host.IncrementScopeField(target, Scope.Field, N) lowers to the active command surface: context.Commands.IncrementScopeField(...) in template methods or ctx.Commands.IncrementScopeField(...) in behavior bodies.

Notes: None.

Game-declared scope sigils

What: For each scope Name { ... } declaration in the domain-owned Content/**/scopes.secs files, the compiler generates a sigil @Name that lowers to a declared walk from the current scope frame. At the top level of a contract/template function, the walk origin is @root; inside a foreach, the walk origin is the current iteration entity. Valenar declares its gameplay scopes across settlement, world, location, character, building, and economy source folders.

SECS (using the sigil reference in an event condition method):

query bool Condition()
{
return @Settlement.resolve(Population) > 20;
}

Source: examples/valenar/Content/events/settlement/crisis/plague_spreads.secs:9.

Compiles to:

public override bool Condition(EventContext context)
{
var scope = context.Scope;
var ctx = context.Tick;
return ctx.ChannelResolver.ResolveIntFast(scope.Owner, H.Population) > 20;
}

Source: examples/valenar/Generated/Events/Settlement/Crisis/PlagueSpreadsEvent.cs. In this particular lowering the @Settlement walk is elided because the event's ContractId = H.SettlementContract guarantees context.Scope.Owner already is a settlement — host.WalkScope(scope.Owner, H.Settlement) would return the same entity. The compiler performs this elision when the contract's root scope matches the sigil's target scope.

Why this shape: Scope sigils name a destination in the scope hierarchy. They do not name a specific entity and they are not method parameters in disguise. The host decides how to walk from the current scope-frame origin to the requested scope destination. Because the walk is pure — it reads relationship data the host already owns — the engine never caches walk results; WalkScope is called fresh on every access and the host is expected to make it O(1) or near-O(1).

Self-walk elision: If @X is used from inside a method whose current scope entity is already of scope X, the walk resolves to self and the compiler may elide it. This is visible in PlagueSpreadsEvent (event bound to SettlementContract, @Settlement elided), and in Formula_FarmFood (owner is a farm building with a non-settlement root; the @Location walk is not elidable — see the explicit host.WalkScope(owner, H.Location) in Generated/Formulas/Formula_FarmFood.cs:17).

Mod scope note: Sigil resolution runs over the exported host-capable scope set produced by the base game plus enabled official expansions. Third-party data-only mods may use those sigils in overrides and new content, but they do not contribute new @Name sigils because adding a scope requires host allocation and walk support. An @Name reference whose underlying scope is not present in the exported set emits a binder diagnostic (proposed SECS0501) pointing at the @Name use site: sigil <Name> refers to a scope not declared by the base game or enabled official expansions. Walk edges (walks_to) are likewise drawn from the exported host-capable scope graph; see 01-world-shape.md § "Host-capable extension of scopes".

Cross-walks (non-root scope): If the sigil names a scope that is not the current root, the walk is explicit. Example from Formula_FarmFood — farm building's root is Building, so @Location.Fertility must walk out to the location first:

// location.Fertility in .secs
var location = host.WalkScope(owner, H.Location);
if (location.IsNull) return 5; // defensive fallback
var fertility = host.ReadInt(location, H.Location, H.Fertility);

Source: examples/valenar/Generated/Formulas/Formula_FarmFood.cs:17-22.

Notes: None.

Scope-walk lowering: general table

What: The canonical shape for scope.X where X is a field, a channel, a method, or a collection. The compiler decides which lowering applies from the scope field / channel / method / collection table in Generated/Declarations.cs.

SECS: The four forms all follow scope.identifier, and identifier disambiguates:

location.Fertility // raw int field read -> host.ReadInt
settlement.resolve(Morale) // channel pipeline re-entry -> ctx.ChannelResolver.ResolveIntFast
location.BuildingCount(id) // query scope method -> generated typed facade over ISecsHostReads.CallScopeQuery
character.DrainStamina(1) // command scope method -> ISecsHostCommands
location.Buildings // child collection -> host.GetChildren (in foreach)

Compiles to:

// Raw field read — scope + fieldId
int fertility = host.ReadInt(location_entity, H.Location, H.Fertility);

// Channel resolution — only takes channelId (channelId is unique across scopes)
int morale = host.ResolveChannelInt(settlement_entity, H.Morale);
// or, inside a system/event body where a resolver is at hand:
int morale = ctx.ChannelResolver.ResolveIntFast(settlement_entity, H.Morale);

// Read-only scope query — scopeId + methodId + typed args/result
int count = LocationScopeQueries.BuildingCount(host, location_entity, H.Farm);

// Void scope method — generated typed command facade + command context
CharacterScopeCommands.DrainStamina(tick, character_entity, 1);

// Collection — only usable in foreach (or count/random/first/any/every)
ReadOnlySpan<EntityHandle> buildings = host.GetChildren(location_entity, H.Location_Buildings);

Source: ISecsHostReads signatures in src/SECS.Abstractions/Interfaces/ISecsHostReads.cs:9-23. Raw read in a formula: Generated/Formulas/Formula_FarmFood.cs:20-21. Channel resolution inside a trigger: Generated/Triggers/Trigger_MoraleGte60.cs:11. Collection iteration is shown in the foreach child in parent.Collection section below.

Why this shape: The compiler picks the lowering from the identifier table — there is no runtime dispatch. Fertility being a field vs a channel is determined at parse time from Generated/Declarations.cs (the ScopeFieldDeclaration[] and ChannelDeclaration[] arrays introduced in doc 01). A single identifier name is unique per-scope for fields and globally unique for channels, so the compiler never needs fallback resolution. Scope-method arguments are ordinary values for the call (ulong templateId in BuildingCount, Location target and int amount in AdvanceLead), not a channel for passing ambient world entities that the scope frame should provide. The return type decides the bridge: non-void methods are read-only and use ISecsHostReads; void methods are command-producing and use ISecsHostCommands.

Notes: None.

Read expressions

scope.field — raw field read

What: Read a raw host-owned scope field. Bypasses the channel pipeline — no base / additive / multiplicative / clamp, no modifier evaluation. Returns whatever the host's bridge layer currently holds for that (entity, scope, field).

SECS:

// From ResourceProductionSystem, scope-field read inside a where-style filter:
int ownerId = loc.OwnerId;
if (ownerId != settlement.id) continue;

Source: examples/valenar/Content/economy/systems/resource_production.secs:18-19.

Compiles to:

int ownerId = ctx.Host.ReadInt(loc, H.Location, H.OwnerId);
if (ownerId != (int)settlement.Value) continue;

Source: examples/valenar/Generated/Systems/Economy/ResourceProductionSystem.cs:55-56.

Why this shape: Raw fields are host-owned data that the engine has no claim over. The engine reads them through typed ISecsHostReads.ReadX methods, each of which takes (entity, scopeId, fieldId) — the scopeId lets the host's bridge pick the right data table, and the fieldId indexes into it. This differs from ReadEntity, which is a raw-field read of an entity reference, not a channel.

Type dispatch:

  • int X; field -> host.ReadInt(target, H.Scope, H.X)
  • long X; field -> host.ReadLong(target, H.Scope, H.X)
  • float X; field -> host.ReadFloat(target, H.Scope, H.X)
  • double X; field -> host.ReadDouble(target, H.Scope, H.X)
  • bool X; field -> host.ReadBool(target, H.Scope, H.X)
  • Source scope fields are scalar-only in the committed .secs surface today. host.ReadEntity(target, H.Scope, H.X) exists as a bridge/runtime surface for future entity-reference fields or host-authored generated code, but entity X; is not promoted as committed source syntax until live runtime/generated/tests depend on typed entity fields end-to-end.

Notes: None.

scope.resolve(Channel) — channel resolution through pipeline

What: Run the channel resolution pipeline on the target entity for the named channel. The runtime executes the committed 6-phase pipeline (Base → Additive → Multiply → HardOverride → Clamp → Return). Re-entrant — a formula can call resolve(OtherStat) inside its body as long as the cycle detector doesn't fire.

The argument must be a declared SECS channel. The channel keyword is the resolver boundary: channel int Defense = 10; inside a template is resolvable on that entity, while field int GoldCost = 10; is template-definition data and is not accepted by resolve(GoldCost). Template fields are read by explicit template-field APIs/generated accessors (GetFieldInt, GetFieldLong, GetFieldFloat, GetFieldDouble, GetFieldBool) or by the template-value resolver below, not through the channel resolver.

SECS:

var population = settlement.resolve(Population);

Source: examples/valenar/Content/economy/systems/tax_collection.secs:13.

Compiles to:

var population = ctx.ChannelResolver.ResolveIntFast(settlement, H.Population);

Source: examples/valenar/Generated/Systems/Economy/TaxCollectionSystem.cs:30.

Why this shape: ChannelResolver.ResolveXFast is the hot-path version of host.ResolveChannelX — it skips the interface dispatch and uses the registry directly. Systems and events running inside the tick loop get a TickContext and use ctx.ChannelResolver. Formulas and triggers, which run in arbitrary contexts including nested re-entry during another channel's resolution, use the typed host.ResolveChannelX interface methods that route through ChannelCache.

Type dispatch:

  • int channel inside system/event body -> ctx.ChannelResolver.ResolveIntFast(target, H.Channel)
  • int channel inside formula/trigger -> host.ResolveChannelInt(target, H.Channel)
  • long channel -> .ResolveLongFast / host.ResolveChannelLong
  • float channel -> .ResolveFloatFast / host.ResolveChannelFloat
  • double channel -> .ResolveDoubleFast / host.ResolveChannelDouble
  • bool channel -> .ResolveBoolFast / host.ResolveChannelBool

Cross-scope form: location.Fertility from a Building context — the scope walk happens first, then the raw field read:

var location = host.WalkScope(owner, H.Location);
var fertility = host.ReadInt(location, H.Location, H.Fertility);

Source: examples/valenar/Generated/Formulas/Formula_FarmFood.cs:17-21.

Notes: None.

Effective template value read

What: Read either the static base template value or the effective template value produced by applying active field modifier bindings over an explicit context chain. This is the template-value analogue of channel resolution, but it is not resolve(...): the input is (template id, field id, ordered context targets), not (entity, channel id).

SECS (source spelling target):

// Base-only: ignores contextual modifiers.
var baseGoldCost = template_field(Farm, GoldCost);

// Effective template value with an explicit ordered context chain.
var localGoldCost = template_field(Farm, GoldCost, context(@Settlement, @Location));

// Effective template value through a contract-owned named context profile.
var profiledGoldCost = template_field(Farm, GoldCost, BuildCostContext);

Farm names the template whose field is read. GoldCost names the top-level field declaration. The two-argument form is base-only and reads the authored template value from the template definition. The three-argument forms are template-value reads: context(@Settlement, @Location) is an ordered list of runtime entities whose active bindings can modify the effective template value, and BuildCostContext is a named profile declared by the template's contract that expands to the same ordered chain.

Example using the current Valenar Building contract shape:

contract Building
{
root_scope Location;
activation OnBuilt;
deactivation OnDestroyed;

query bool CanBuild();
method void OnBuilt();
method void OnDestroyed();
method void OnDayTick();

context BuildCostContext
{
@Settlement;
@Location;
}
}

template<Building> Farm
{
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);
}
}

The named profile is only context selection. It does not validate that @Settlement exists, choose who pays, compare resources, spend resources, or decide whether a template is legal. That logic belongs in CanBuild() or another contract-declared query/method body.

Compiles to inside generated/host code:

// template_field(Farm, GoldCost)
var baseGoldCost = registry.GetTemplateFieldInt(H.Farm, H.GoldCost);

// template_field(Farm, GoldCost, context(@Settlement, @Location))
// template_field(Farm, GoldCost, BuildCostContext) expands to the same list.
Span<EntityHandle> rawContexts = stackalloc EntityHandle[] { settlement, location };
Span<EntityHandle> contexts = stackalloc EntityHandle[rawContexts.Length];
var count = 0;

foreach (var candidate in rawContexts)
{
if (candidate.IsNull)
{
continue;
}

if (contexts[..count].Contains(candidate))
{
continue;
}

contexts[count++] = candidate;
}

var goldCost = host.ResolveTemplateValueInt(H.Farm, H.GoldCost, contexts[..count]);

ctx.TemplateValues.ResolveInt(...) is the same resolver when a TickContext is already available. Generated queries use the ISecsHostReads.ResolveTemplateValueInt bridge because query delegates receive a read boundary, not a command-producing tick context.

For base-only reads, generated/host code can call registry.GetTemplateFieldInt(H.Farm, H.GoldCost) or the template's generated GetFieldInt accessor directly. No context list is created, no modifier binding is scanned, and the value is static after registry registration.

Context-chain semantics: Contexts are ordered broad-to-narrow by source convention: context(@Country, @Settlement, @Location) means country-wide rules apply first, settlement rules apply next, and location-specific rules apply last. The compiler/runtime normalizes the chain before resolution:

  • Evaluate context expressions left to right.
  • Skip null context entities.
  • Skip duplicate EntityHandle values while preserving the first occurrence.
  • Pass the normalized ordered chain to the resolver.

The resolver phases are base template field value -> additive template-value effects -> multiplicative template-value effects -> HardOverride template-value effects -> clamp from TemplateFieldDeclaration -> return. Context targets are visited in normalized caller order, and bindings within each target use modifier-binding registration order. For HardOverride, the last active override encountered wins, so a later, narrower context wins over an earlier, broader context. If the same entity appears twice, only its first position participates; a duplicate later in the list does not get a second chance to win.

Why this shape: The context is explicit and game-authored. A Farm does not need to exist for its GoldCost to be read, but discounts are usually attached to some runtime scope: settlement laws, location designations, actor traits, event buffs. Another game may use the same API for mana costs, spawn weights, research tiers, card costs, or item weights. Passing the context entity list lets the resolver reuse the existing ModifierBindingStore without inventing a "template instance" just to read a field. Multi-context reads (actor + settlement + realm) are handled by the resolver API's context-target list; no extra contexts are inferred from the receiver, template id, field id, or caller.

Caching: Base template fields are static after registration and may be cached forever by (templateId, fieldId, scalarType). Effective template-value reads must include the normalized ordered context chain in the key: (templateId, fieldId, scalarType, [context0, context1, ...]). Any cache for effective template values also needs modifier/version invalidation. A short-lived cache scoped to one tick, one UI paint, or one planner pass is allowed because it dies before stale context is likely to matter. A long-lived cache must track per-entity modifier versions for every context entity and invalidate when bindings are added, removed, expire, toggle active/inactive, or change stack/decay state. If an active template-value effect is dynamic, the cache must also honor that formula's read set and invalidate when any channel or host field read by the formula changes; a cache that cannot prove those dynamic read-set versions must treat that template-value read as tick-local or uncacheable.

Notes: None for the committed primitive. Multi-context reads always use explicit context(...) or a named contract-owned profile; no receiver form may imply hidden context targets.

Host-exposed scope methods

What: Call a method declared on the receiver's scope. Non-void methods are read-only and can appear anywhere a value of their return type is legal. void methods are command-producing and can appear only in bodies that have a command context.

SECS:

// Read-only host query declared on scope Location.
var hasWatchtower = location.BuildingCount<Watchtower>() > 0;

// Command-producing host methods declared on scopes character and location.
actor.DrainStamina(1);
actor.AdvanceLead(target, 25);
target.Reveal();
actor.GainXp(5);

Valenar declares these methods in domain scope files such as examples/valenar/Content/locations/scopes.secs and examples/valenar/Content/characters/scopes.secs:

scope Location
{
query int BuildingCount(TemplateId templateId);
method void Reveal();
}

scope Character
{
method void DrainStamina(int amount);
method void GainXp(int amount);
method void AdvanceLead(Location target, int amount);
}

Compiles to:

// Read-only method. Valid in query/condition/formula contexts.
var hasWatchtower =
LocationScopeQueries.BuildingCount(host, location, new TemplateId(H.Watchtower)) > 0;

// Void command methods. Valid only with a command context.
CharacterScopeCommands.DrainStamina(ctx.Tick, ctx.Actor, 1);
ctx.FlushCommands();

CharacterScopeCommands.AdvanceLead(ctx.Tick, ctx.Actor, ctx.Target, 25);
ctx.FlushCommands();

LocationScopeCommands.Reveal(ctx.Tick, ctx.Target);
ctx.FlushCommands();

Why this shape: Scope methods are game-owned host bridge calls, not new SECS syntax. The source expression is ordinary C# method-call shape on a typed scope receiver; the scope declaration tells the compiler whether the call is read-only or command-producing, how many arguments it accepts, and what parameter types those arguments must have. DrainStamina, GainXp, AdvanceLead, and Reveal are Valenar names; another game can declare a different set of scope methods without changing the activity lifecycle model.

void methods require the command bridge because they may mutate host state or enqueue host-owned commands. A CanStart() body can read actor.CurrentLocationId and call read-only methods, but it cannot call actor.DrainStamina(1) because no command context exists. OnStart, OnUpdate, OnComplete, OnCancel, template contract methods, systems, and events all have command context and may call void scope methods.

Callable ABI rule: Generated scope-call facades use concrete C# parameter and return types selected from ScopeMethodDeclaration / SecsTypeRef. For example, CharacterScopeCommands.AdvanceLead(...) constructs a typed CharacterAdvanceLeadCommandArgs value and forwards it through the active command context. SecsValue is retained only for command payload storage and explicitly erased dynamic/tooling adapters; it is not the normal scope-call ABI. Generated template method, system, event, and activity bodies flush the active command buffer with FlushCommands() after each command-producing source statement.

Source-owned helper methods

Plain C# helper classes may live in .secs files outside restricted declarations. They are source-owned code, not generated-only behavior. Activity, event, system, template, and formula bodies may call those helpers with ordinary C# call syntax:

public static class CharacterSiteActivityLogic
{
public static bool CanStart(Character actor, Location target, CharacterSiteActivityFields fields)
{
return fields.RequiredSkillTemplateId.IsNone
|| actor.SkillLevel(fields.RequiredSkillTemplateId) >= fields.RequiredSkillLevel;
}
}

activity HuntGame
{
actor Character;
target Location;

query bool CanStart()
{
return CharacterSiteActivityLogic.CanStart(actor, target, Fields);
}
}

Helper calls inherit the read/write legality of the call site. A helper reached from a query can read scope fields and call query scope methods, but cannot call command-producing method void scope methods. A helper reached from OnUpdate, a system Execute, an event command method, or a template command method may call command-producing scope methods, and those calls lower through the same command bridge and flush rule as if they were written inline. Reusable game vocabulary such as CharacterSiteActivityLogic therefore belongs in .secs source or a real referenced C# library, not as an unowned implementation detail inside Generated/.

Contract query and method calls

What: Call a query or void method declared by a template's contract through the generic contract-call API. Queries are read-only expressions whose source return type is the declared concrete C# type (bool, enum, struct, record, array, collection-shaped type, scope entity type, etc.). Methods are command-producing calls, return void, and require ITemplateCommandContext or an equivalent command context in scope. Both surfaces use the same source/host addressing shape: template id, query/method id, typed source arguments lowered to SecsTypeRef metadata, and an explicit scope-frame context.

Generated helper pseudocode (not committed source syntax):

var canBuildFarm = contract_query<bool>(Farm, CanBuild, context(root: @Location));
contract_method(Farm, OnBuilt, context(owner: @this, root: @Location));
var canIrrigate = transition_query<bool>(@this, IrrigatedFarm);
var placement = contract_query<FeaturePlacementResult>(
Dungeon,
EvaluatePlacement,
context(root: @Feature),
candidate,
placementInput);

Farm names the template whose contract declares CanBuild and OnBuilt. context(root: @Location) creates a creation-time frame: the query sees root = @Location and has no this / owner. context(owner: @this, root: @Location) creates an existing-instance frame for a void method call: the method sees both the owner and the contract root, and writes through the template command context in scope. transition_query is a convenience over the transition table for an existing owner entity; it evaluates the query bound to the (current template, IrrigatedFarm) edge, not an unrelated query on IrrigatedFarm.

The compiler resolves CanBuild / OnBuilt / EvaluatePlacement through the generated ContractMethodDeclaration[] table for the template's contract. That table supplies the owner, full signature id, return SecsTypeRef, parameter SecsTypeRefs, and query-vs-command bit. Runtime calls carry ids, a ScopeFrame, and typed arguments. Erased adapters may carry value spans internally, but signature metadata is SecsTypeRef for binding, diagnostics, tooling, startup validation, argument-type checks, and query return-type checks.

Compiles to inside generated/host code (target shape):

var canBuildFarm = registry.EvaluateTemplateQuery<bool>(
H.Farm,
H.Contract_Building_CanBuild_NoArgs_Bool,
ScopeFrame.ForCreation(location),
host);

activator.CallMethod(
H.Farm,
H.Contract_Building_OnBuilt_NoArgs_Void,
farmEntity,
location);

var canIrrigate = transitions.QueryBool(farmEntity, H.IrrigatedFarm, host);

var placementArgs = new FeaturePlacementQueryArgs(candidateLocation, placementInput);
var placement = registry.EvaluateTemplateQuery<FeaturePlacementQueryArgs, FeaturePlacementResult>(
H.Dungeon,
H.Contract_Feature_EvaluatePlacement_Location_FeaturePlacementInput_FeaturePlacementResult,
ScopeFrame.ForCreation(featureRoot),
in placementArgs,
host);

When a TickContext is already available, the same calls route through the registry/activator services on ctx rather than a standalone host bridge. The runtime ABI is committed at the type level: queries use (template id, query id, ScopeFrame, typed args, ISecsHostReads) and return the declared type whose SecsTypeRef must match ContractMethodDeclaration.ReturnType; void methods use (template id, method id, owner, root, typed args) and require a command context. Transition helpers keep the edge-owned (owner entity, to template id) shape. Those runtime arguments construct the scope frame; they are not source-level world parameters. The runtime derives the source template and root scope target from an existing owner entity when the caller uses a transition helper.

Why this shape: Contract queries and methods are callable API surfaces, not lifecycle-private hooks and not fields. A host UI, AI planner, SECS system, or event can all ask the same read-only query and receive the same answer because the question is framed as "evaluate this query in this scope frame," not "call a C# method with whichever world parameters the caller happened to pass." Void methods use the same identity and frame rules but run through ITemplateCommandContext because they are allowed to produce commands and must flush them at statement boundaries. Lifecycle bindings are just automatic calls to this same void-method surface: activation OnBuilt; and activation OnEquipped; differ only in the developer-chosen method id. Transition query bindings stay edge-owned: a source template with two outgoing edges evaluates the query on the requested edge only, so branching decisions are explicit and target-template CanBuild logic is never applied by accident.

Feature placement call rule: Systems that generate features call EvaluatePlacement(Location candidate, FeaturePlacementInput input) as a read-only contract query across candidate templates, collect FeaturePlacementResult values, and only then apply global budgets, spacing, uniqueness, and conflict resolution. A placement query returning Eligible = true is not a placement side effect.

Notes:

  • Final helper names. contract_query<T>, contract_method, and transition_query<T> are the design-target spellings in this document; the committed part is the generic typed API shape and explicit context(...) frame, not the exact helper class name in generated C#.
  • Whether a transition edge with no query binding lowers to a missing delegate or an always-true delegate. Current target: missing delegate means true, but the registry still records the edge.

Bare resolve(Channel) — implicit this

What: Shorthand for this.resolve(Channel). Resolves the named channel on whichever entity is the current existing scope entity — the lifecycle/transition owner at top level, or the iteration variable inside a foreach. It is not available in creation-time query calls because those frames have no this; creation-time queries must spell the root explicitly as root.resolve(Channel) or use a declared scope sigil such as @Location.resolve(Channel).

SECS:

// Inside a Character template lifecycle method whose root_scope is Character:
if (resolve(Health) <= 0) { ... } // equivalent to this.resolve(Health)

(Adapted — Valenar does not currently emit bare resolve() in any .secs file, but this shorthand is part of the committed live expression surface and lowers as shown here.)

Compiles to:

// Inside an existing-instance template method: this is owner
if (host.ResolveChannelInt(owner, H.Health) <= 0) { ... }

// Inside a system Execute body after `foreach settlement in Settlement`:
if (ctx.ChannelResolver.ResolveIntFast(settlement, H.Health) <= 0) { ... }

Why this shape: The compiler rewrites bare resolve(X) by prepending the current this local. Inside existing-instance template methods this is the owner parameter; inside systems this is the foreach iteration variable. There is no fallback behavior — if the compiler cannot name the current this, the .secs is malformed.

Notes: None.

Write expressions

All write expressions require a command surface in scope: context.Commands inside template contract methods, ctx.Commands inside generated system/event bodies, or activity-run context commands inside generated activities. Writes are queued, not applied immediately. Generated template method, system, event, and activity bodies flush with context.FlushCommands() / ctx.FlushCommands() after each command-producing source statement. Template Activate is the exception: it is compiler-emitted intrinsic channel-source registration and still uses a private CommandBuffer flushed by the runtime boundary.

create_entity — runtime entity creation

What: Create an entity from a registered template from inside an ordinary C# method body that has a command-capable SECS execution context. This is an expression that returns the new entity handle; callers may use it as a statement when they do not need the handle. It is not a template-body metadata row.

SECS:

// Bare form inside a Character method: parent to the ambient Character root.
create_entity Walking;

// Creation returns the new entity handle.
var scouting = create_entity Surveying with { Level = 1; XP = 45; };

// Explicit receiver form: parent to that scope target.
var district = settlement.create_entity ResidentialDistrict with { StartingPopulation = 50; };

// Geography uses implicit Bare templates as ordinary template ids.
var region = create_entity BareRegion;
var area = region.create_entity BareArea;
var province = area.create_entity BareProvince;
province.create_entity BareLocation;

Compiles to:

context.CreateEntity(new TemplateId(H.Walking));

var scouting = 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),
});

var district = context.CreateEntity(
new TemplateId(H.ResidentialDistrict),
settlement,
new Dictionary<ulong, ITemplateValue>
{
[H.StartingPopulation] = TemplateData.Structured(
H.StartingPopulation,
SecsTypeRef.Int,
50),
});

var region = ctx.CreateEntity(new TemplateId(H.BareRegion));
var area = ctx.CreateEntity(new TemplateId(H.BareArea), region);
var province = ctx.CreateEntity(new TemplateId(H.BareProvince), area);
ctx.CreateEntity(new TemplateId(H.BareLocation), province);

ITemplateCommandContext.CreateEntity uses context.Scope.Root for the bare form. TickContext.CreateEntity has no ambient entity, so generated system code passes the scope target explicitly when the new entity should be parented. The template operand may be a static template symbol (BareLocation) or a runtime template-id value (placement.Template) when the value is already typed by the surrounding query/system code.

Why this shape:

  • Creation is synchronous. Pending queued writes are flushed before the entity is created so source statement order remains ordinary and deterministic.

  • with { Field = Value; } seeds root-scope host fields before the new template's activation runs. It is for initial state only.

  • rules { ... } is not valid in a creation block. Rules, channels, lifecycle methods, and modifiers live on templates; create_entity selects which template to instantiate and supplies initial data.

  • Conditional creation uses ordinary control flow or the existing when pattern if the compiler later admits a statement suffix:

    if (WorldConfig.EnableCombat)
    create_entity Combat with { StartingLevel = 2; };

scope.increment(Channel, N) — scope-field increment

What: Queue a command to atomically add N to the named scope field on target. Note the subtlety: despite the Channel spelling in .secs, the lowering target is a scope field (context.Commands.IncrementScopeField / ctx.Commands.IncrementScopeField), not a channel source — the compiler knows which identifiers are fields (mutable host state) versus channels (engine-resolved values) from the declaration tables and picks the right lowering.

SECS:

settlement.increment(Gold, income);

Source: examples/valenar/Content/economy/systems/tax_collection.secs:15.

Compiles to:

ctx.Commands.IncrementScopeField(settlement, H.Settlement, H.Gold, income);

Source: examples/valenar/Generated/Systems/Economy/TaxCollectionSystem.cs:34. The extension method signature is IncrementScopeField(CommandBuffer cmds, EntityHandle target, ulong scopeId, ulong fieldId, int delta) at src/SECS.Abstractions/Commands/CommandBufferExtensions.cs:107-118.

Why this shape: Template intrinsic channel sources are registered once on activation and removed once on deactivation; they belong to the channel resolver's phase-1 input. Scope fields are mutable host state that changes tick by tick — gold spent, population killed, morale shifted. The increment keyword targets the mutable pool, not the intrinsic channel-source pool. IncrementScopeField dispatches to the host via the CommandType.IncrementScopeField branch of CommandProcessor, which ultimately calls ISecsHostWrites.WriteInt.

Sign convention: N may be negative. settlement.increment(Population, -casualties) lowers to the active command buffer, e.g. ctx.Commands.IncrementScopeField(settlement, H.Settlement, H.Population, -casualties). Clamps declared on the scope field are applied by the host bridge, not by IncrementScopeField itself.

Typing: The compiler dispatches by the declared scope-field scalar type. int, long, float, and double fields lower to IncrementScopeField, IncrementScopeFieldLong, IncrementScopeFieldFloat, and IncrementScopeFieldDouble respectively; each helper populates the matching Command payload slot. bool fields reject increment at bind time because additive mutation has no boolean meaning.

Notes: None.

target.add_modifier Name [clauses] — attach modifier binding

What: Queue a CommandType.RegisterModifierBinding command that creates one ModifierBinding with owner, target, modifier id, optional continuous trigger, duration ticks, captured scope references, and state.

The committed source shape is:

target.add_modifier ModifierName owned_by ownerExpr when condition while condition duration N;

All clauses after ModifierName are optional. The canonical order is owned_by, when, while, duration. target is always the receiver expression.

SECS — Valenar call sites:

// House: template-method default owner = @owner / scope.Owner, target = @Settlement.
@Settlement.add_modifier HouseMoralePresence;

// DemonRaid: outside a template method, default owner = target = @Settlement.
@Settlement.add_modifier DemonDread duration 3;

// CelebrationBonus: Location target, explicit Settlement lifetime owner.
location.add_modifier BountifulHarvest owned_by @Settlement duration 5;

// FestivalSpirit: one-shot gate, then a 3-tick binding if morale passes.
@Settlement.add_modifier FestivalSpirit
when @Settlement.resolve(Morale) >= 80 duration 3;

// Thriving: permanent binding whose effects are active only while morale passes.
@Settlement.add_modifier Thriving
while @Settlement.resolve(Morale) >= 80;

Compiles to:

public static void AddModifier(this CommandBuffer cmds,
EntityHandle owner, EntityHandle target, ulong modifierId,
ulong triggerId = 0, int duration = -1,
EntityHandle captured0 = default, EntityHandle captured1 = default,
ReApplyMode reApplyMode = ReApplyMode.Default)

// House template method: owner defaults to the template instance, target is receiver.
context.Commands.AddModifier(context.Scope.Owner, settlement, H.HouseMoralePresence, 0, -1);
context.FlushCommands();

// DemonRaid event: outside template method, receiver supplies both owner and target.
ctx.Commands.AddModifier(settlement, settlement, H.DemonDread, 0, 3);

// CelebrationBonus location bonus: explicit owned_by supplies owner.
ctx.Commands.AddModifier(settlement, location, H.BountifulHarvest, 0, 5);

// when: trigger evaluated inline; no TriggerId is stored.
if (Trigger_MoraleGte80.Evaluate(settlement, EntityHandle.Null, EntityHandle.Null, ctx.Host))
{
ctx.Commands.AddModifier(settlement, settlement, H.FestivalSpirit, 0, 3);
}

// while: TriggerId is stored and re-evaluated by the binding update pass.
ctx.Commands.AddModifier(settlement, settlement, H.Thriving, H.Trigger_MoraleGte80, -1);

Why this shape: The receiver already names the effect anchor, so a separate retargeting clause is not needed. owned_by names the lifetime source when it differs from the target. That keeps the runtime binding record direct: Owner, Target, ModifierId, TriggerId, RemainingTicks, TotalTicks, Captured0, Captured1, State.

Owner / target convention:

owner means the lifetime source: the entity whose destruction/deactivation removes the binding. target means the effect anchor: the entity whose channel-or-template-value context receives the modifier. They are separate on purpose.

Source siteDefault ownerTarget
Existing-instance template method@owner / scope.OwnerReceiver expression (@Settlement, @this, @Location, etc.)
System/event/on-action/activity bodyReceiver expressionReceiver expression
Any site with owned_by EEReceiver expression

Every persistent binding must have a non-null owner. If there is no separate cause, use owner = target. Global effects still need a real owner entity (World, Realm, Country, a law instance, etc.); do not use a magic null owner.

Plain examples:

SituationOwnerTargetWhy
House gives HouseMoralePresence to its settlementHouse buildingSettlementDestroying one House removes only that House's stack.
Demon raid applies DemonDreadSettlementSettlementThe settlement owns its temporary fear binding.
Celebration gives BountifulHarvest to one locationSettlementLocationThe event/settlement owns the 5-tick cause; the location receives the output effect.
Realm law discounts construction in a settlementLaw/Realm entitySettlementChanging the law removes all bindings caused by that law.

Cleanup follows directly:

owner dies / deactivates -> remove bindings owned by it
target dies / deactivates -> remove bindings targeting it
duration reaches zero -> remove that binding
while condition false -> keep binding, set State = Inactive
while condition true -> set State = Active

The current runtime enforces owner/target cleanup in TemplateActivator.Destroy: after the contract's deactivation lifecycle binding runs, it queues RemoveAllChannelSources(owner), RemoveAllBindings(owner), and RemoveAllBindingsOnTarget(owner). Therefore a template-owned binding such as @Settlement.add_modifier HouseMoralePresence does not need a source-level OnDestroyed method purely to avoid leaking. Write remove_modifier only when gameplay wants early/custom removal before the owner or target is destroyed.

For effective template values, the target is the context that should affect the read. "Farm cost in Settlement A" uses bindings targeted at Settlement A. "Farm cost at Location X under Realm R" may pass context targets [Settlement A, Location X, Realm R] to the template-value resolver; each target contributes its active template-value effects in caller-specified order.

when vs while:

  • when E is a one-shot gate. The compiler evaluates the trigger before queuing the command; if false, no binding is created. The trigger id is not stored.
  • while E is continuous. The compiler stores TriggerId = H.Trigger_* on the binding. The binding update pass re-evaluates it; false toggles State = Inactive, true toggles State = Active.
  • when E while F is legal: E gates creation once, then F controls active/inactive state.
  • duration N is measured in ticks and counts down whether the binding is active or inactive. There are no built-in day/month units.

Captured entities: The optional captured0 / captured1 command arguments bake entity references into the binding for later trigger re-evaluation. The compiler populates them when a stored while trigger references @root, @prev, or an outer/saved scope that is not the binding target. A missing required capture makes the trigger false. Captures are not owners and do not keep the captured entity alive.

location.add_modifier BountifulHarvest owned_by @Settlement
while @Settlement.resolve(Morale) >= 60 duration 5;
// target = location; owner = settlement; trigger reads settlement via captured0.
ctx.Commands.AddModifier(
owner: settlement,
target: location,
modifierId: H.BountifulHarvest,
triggerId: H.Trigger_MoraleGte60,
duration: 5,
captured0: settlement);

ReApplyMode override: The per-call reApplyMode argument overrides the modifier declaration's default. The author-facing behaviours are ignore, refresh, extend, and stack (see doc 03). Lowering is context.Commands.AddModifier(owner, target, H.X, ..., reApplyMode: ReApplyMode.Refresh) in template methods or the equivalent active command buffer in systems/events/activities.

Mod scope note: add_modifier Name resolves Name against the merged modifier set: first the enclosing template's scoped modifiers (if any), then the global modifier table. The compiler lowers the resolved symbol to the modifier's canonical id hash, so a mod-authored template may attach a base-declared modifier and a base-authored template (post-merge) may attach a mod-declared modifier — the runtime sees one flat hash space and does not distinguish provenance. An unresolved add_modifier Name emits a binder diagnostic with the modifier name; if the compiler can trace which source set authored the missing modifier, the message names the dependency mod.

Notes: None.

target.remove_modifier Name [owned_by ownerExpr] — detach modifier binding

What: Queue a CommandType.RemoveModifierBinding command that detaches the named modifier from the receiver target.

The committed source shape is:

target.remove_modifier ModifierName owned_by ownerExpr;

The owned_by clause is optional. If it is omitted inside an existing-instance template method, the owner defaults to the current template instance. If it is omitted outside a template method, owner defaults to the receiver target. This matches add_modifier.

SECS:

method void OnCleared()
{
remove_modifier DungeonAuraOfDread;
add_modifier DungeonClearedReward;
}

method void OnUnequipped()
{
@Character.remove_modifier EquippedItemAttack owned_by @Item;
}

Compiles to:

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();
}

public static void OnUnequipped(ITemplateCommandContext context, in SecsNoArgs args)
{
var character = context.Host.WalkScope(context.Scope.Root, H.Character);
if (!character.IsNull)
{
context.Commands.RemoveModifier(context.Scope.Owner, character, H.EquippedItemAttack);
context.FlushCommands();
}
}

Source of the helper: src/SECS.Abstractions/Commands/CommandBufferExtensions.cs.

Why this shape: The command semantics are the exact inverse of add_modifier: (owner, target, modifierId) identifies one binding in the runtime's binding store. The receiver keeps the effect anchor explicit, and owned_by keeps early/custom removal able to name a lifetime source that is not the target.

Notes: None.

remove_modifiers_by_tag Tag / remove_modifiers_by_tag_from_owner Tag

What: Bulk-remove bindings by modifier tag rather than by name. Two variants: remove all bindings on target with a given tag, or remove only those owned by a specific owner.

SECS (adapted — the keyword is not used in current Valenar .secs but the runtime helpers exist):

// Clear all quarantine-related modifiers from settlement
settlement.remove_modifiers_by_tag Disease;

// Clear only my modifiers (owned by this template instance) with tag Buff
remove_modifiers_by_tag_from_owner Buff;

Compiles to:

// remove_modifiers_by_tag Tag
ctx.Commands.RemoveModifiersByTag(target, H.Tag_Disease);

// remove_modifiers_by_tag_from_owner Tag
ctx.Commands.RemoveModifiersByTagFromOwner(owner, target, H.Tag_Buff);

Source: helper signatures at src/SECS.Abstractions/Commands/CommandBufferExtensions.cs:120-143. Command types: CommandType.RemoveModifiersByTag and CommandType.RemoveModifiersByTagFromOwner (src/SECS.Abstractions/Commands/Command.cs:12-13).

Why this shape: Tags are declared on modifier declarations (see doc 03) and stored on every binding. The runtime maintains a tag -> bindings index, so these helpers are O(bindings-with-tag) rather than O(all-bindings). Two variants exist because "clear all debuffs on settlement" is different from "clear only the debuffs I (this template) applied" — owner filtering prevents a template from clobbering another template's bindings.

Notes: None — helpers exist; keyword surface is pending.

fire on_action Name target E

What: Dispatch an on-action from inside a method body. Events are usually fired by the engine's pulse/on-action cycle; the committed source form is specifically fire on_action ..., which delegates selection-mode handling to the on-action dispatcher.

SECS:

// From demon_raid.secs — fire an on-action with the current settlement as target
save_scope_as was_victory (garrison >= enemyStrength ? 1 : 0);
fire on_action on_raid_resolved target settlement;

Source: examples/valenar/Content/events/combat/raids/demon_raid.secs:31-32.

Compiles to:

ctx.SaveScope(H.WasVictory, new EntityHandle(garrison >= enemyStrength ? 1 : 0));
ctx.Events.FireOnAction(H.OnAction_RaidResolved, target, ctx);

Source: examples/valenar/Generated/Events/Combat/Raids/DemonRaidEvent.cs:54-55. The dispatcher method is EventDispatcher.FireOnAction(ulong onActionId, EntityHandle target, TickContext ctx).

Why this shape: On-actions are named extension points with declared selection modes (all, first_valid, weighted) that matter — the engine's FireOnAction handles mode dispatch centrally. Delegating to EventDispatcher.FireOnAction rather than enumerating subscribed events inline means the caller gets consistent mode handling (weighted RNG, first-valid short-circuit, fallback chains) without reimplementing that logic at every fire site.

Bare fire EventName is not committed source syntax. Runtime/generated helpers may eventually support direct event firing, but arbitrary direct event fire needs source syntax, diagnostics, generated provenance, runtime semantics, and tests before it can move out of FUTURE_WORK.md. The only committed author-facing form in this section is fire on_action ....

Possible future lowering shape:

ctx.Events.FireEventOn(H.MyEvent, target, ctx);

This would fire a specific event by id rather than dispatching through an on-action, but it is future work, not current .secs vocabulary.

Notes: None.

save_scope_as name / scope:name

What: Save a named EntityHandle into the current behavior dispatch's saved-scope table so later statements in the same dispatch can reference it. The saved-scope table lives on TickContext today; the intended compiler contract is a named binding carried by the current execution frame. This section is about save_scope_as / scope:name, not about committing any on-action or action body-block syntax.

SECS — save:

// From demon_raid.secs — save a boolean flag as an EntityHandle (value field encodes 0/1)
save_scope_as was_victory (garrison >= enemyStrength ? 1 : 0);

Source: examples/valenar/Content/events/combat/raids/demon_raid.secs:31.

SECS — read (another event in the same on-action dispatch):

// From raid_resolved_victory.secs
query bool Condition()
{
return scope:WasVictory == 1;
}

Source: examples/valenar/Content/events/combat/raids/raid_resolved_victory.secs:8.

SECS — save/read an actual entity (from on_action declaration):

// ConstructionSystem saves scope:Location before firing on_building_complete
var location = scope:Location;
if (location != null) {
location.add_modifier BountifulHarvest owned_by @Settlement duration 5;
}

Source: examples/valenar/Content/events/settlement/construction/celebration_bonus.secs:21-24.

Compiles to — save:

// "Save int flag as EntityHandle" — Value field encodes the flag
ctx.SaveScope(H.WasVictory, new EntityHandle(garrison >= enemyStrength ? 1 : 0));

Source: examples/valenar/Generated/Events/Combat/Raids/DemonRaidEvent.cs:54.

Compiles to — read:

// Flag read (reads the Value field of the EntityHandle)
var wasVictory = ctx.GetSavedScope(H.WasVictory);
return wasVictory.Value == 1;

Source: examples/valenar/Generated/Events/Combat/Raids/RaidResolvedVictoryEvent.cs:37-38.

// Actual entity read
var location = ctx.GetSavedScope(H.Location);
if (!location.IsNull) {
ctx.Commands.AddModifier(scope.Owner, location, H.BountifulHarvest, 0, 5);
ctx.FlushCommands();
}

Source: examples/valenar/Generated/Events/Settlement/Construction/CelebrationBonusEvent.cs:41-47.

Why this shape: Dispatches such as an on-action firing, a deferred event-option execution, or a system's per-entity iteration body need a way to share named entity references without threading arguments through every call. Runtime storage is frame-based: TickContext keeps a stack of saved-scope dictionaries for active dispatches plus a pending pre-fire table for host/system code that saves scopes immediately before FireOnAction. The keys are compiler-emitted H.* ids for saved-scope wire identifiers, not ad hoc runtime strings. Using EntityHandle.Value as a plain integer carrier is an explicit design choice — it lets the same mechanism carry both entity references (.Value is the entity id) and int flags (.Value is the flag).

Provided scopes from on-actions: On-action declarations (doc 04) can list provides scope:X — these are the saved scopes an on-action guarantees are in place when its subscribers fire. See on_raid_resolved { provides scope:WasVictory; } in Content/on_actions/combat/raid_resolved.secs and the compiler-facing metadata in Generated/OnActions/Combat/CombatOnActionDeclarations.cs. The runtime validates that each declared key is present before dispatch; the compiler will use the same metadata to validate scope:X reads and fire on_action save coverage at build time.

Scope lifecycle rules (brief):

  • Saved scopes are visible for the current dispatch frame. Subscriber events in one on-action share that frame, so later subscribers see names saved by earlier subscribers.
  • FireOnAction pushes a child frame copied from the caller's visible scopes and pops it on return. Top-level pre-fire saves are consumed by that fire and do not remain globally visible.
  • Nested on-actions inherit a snapshot of the caller frame. Child writes do not leak back to the caller frame.
  • Deferred choices (player-presented event options) snapshot the current frame when queued and execute later inside that snapshot, preserving any ambient frame or pending pre-fire scopes around ResolveChoice.
  • A declared provides scope:X is enforced at runtime by key presence when the on-action fires. EntityHandle.Null is still a present value, which is how scope:WasVictory == 0 remains valid.

Mod scope note: save_scope_as name keys are generated ids for saved-scope wire identifiers. Two events from different source sets that both subscribe to the same on-action and both write save_scope_as result expr; collide only if they bind to the same saved-scope id inside that dispatch frame — the last writer wins (subsequent reads see the later value, matching deterministic event firing order under mode all / priority sorting). Across dispatch frames there is no collision: a nested fire receives a copy, and the frame is popped after dispatch. Mods that need a private saved-scope name should use a source-set-specific saved-scope id rather than a shared generic id such as result; the compiler should surface ambiguous or undeclared saved-scope ids once this syntax graduates from stand-in lowering.

Notes:

  • save_temporary_scope_as (scoped to a single statement rather than the chain) is DEFERRED. The current runtime only supports chain-scoped save_scope_as, and no statement-scoped cleanup/plumbing exists yet.

Iteration

foreach entity in Contract — iterate over a contract's entities

What: Iterate every entity registered under the named contract. Lowers to ctx.InstanceStore.AllByContract(contractId) (or the tick-distributed AllByContractForTick variant when the enclosing system carries a frequency = Cadence.Monthly; slot).

SECS:

foreach settlement in Settlement
{
var population = settlement.resolve(Population);
var income = population * 1;
settlement.increment(Gold, income);
}

Source: examples/valenar/Content/economy/systems/tax_collection.secs:11-16.

Compiles to (distributed — monthly):

foreach (var settlement in ctx.InstanceStore.AllByContractForTick(
H.SettlementContract, ctx.TickNumber, DistributedFrequency))
{
var population = ctx.ChannelResolver.ResolveIntFast(settlement, H.Population);
var taxPerPerson = 1;
var income = population * taxPerPerson;

ctx.Commands.IncrementScopeField(settlement, H.Settlement, H.Gold, income);
ctx.FlushCommands();
}

Source: examples/valenar/Generated/Systems/Economy/TaxCollectionSystem.cs:27-37.

Compiles to (undistributed — daily, every tick):

foreach (var settlement in ctx.InstanceStore.AllByContract(H.SettlementContract))
{
// ...
}

Source: examples/valenar/Generated/Systems/Economy/ResourceProductionSystem.cs:45.

Why this shape: The compiler maps the target identifier (Settlement) to its contract (H.SettlementContract) via the scope/contract declarations in Generated/Declarations.cs. AllByContract returns a zero-allocation ref-struct enumerator over the InstanceStore's contract index. AllByContractForTick is the same iterator with a (tickNumber, distribution) -> (entityId + tickNumber) % distribution == 0 filter applied, spreading expensive per-entity work across a distribution window (see the system ... frequency = Cadence.Monthly; lowering in doc 04).

Identifier convention: The SECS target name is the scope name (Settlement), not the contract name (SettlementContract). The compiler resolves the scope to its primary contract — the contract whose root_scope matches the named scope and is registered as the default contract for that scope (see SecsRegistry.FindContractByRootScope and GetDefaultTemplate).

Mod scope note: AllByContract returns every entity registered under the named contract, including mod-authored templates that implement that contract. A base system iterating foreach settlement in Settlement sees both base settlements and any template<Settlement> FloatingCity a mod registers — the iterator does not filter by source set. Per-template behavioural differentiation is not an iterator-level feature: a system body that needs different logic for mod-added templates must dispatch through contract-declared methods (see 01-world-shape.md) or branch on a template-specific channel. Mod-authored systems iterating base contracts work the same way in reverse.

Notes: None.

foreach child in parent.Collection — iterate a parent's child collection

What: Iterate the children of parent in the named collection. Lowers to host.GetChildren(parent, H.Collection), which returns a ReadOnlySpan<EntityHandle>.

SECS:

foreach location in province.Locations
{
totalFood += location.resolve(FoodOutput);
}

(Adapted — Valenar's ResourceProductionSystem currently uses contract-wide iteration with an OwnerId filter; this example documents the committed lowering for collection iteration.)

Compiles to:

var root = province; // root — never changes
ReadOnlySpan<EntityHandle> locations = host.GetChildren(province, H.Province_Locations);
for (int i = 0; i < locations.Length; i++)
{
var location = locations[i]; // this at depth 1, prev = province
ctx.Commands.AddModifier(settlement, location, H.BountifulHarvest,
H.Trigger_MoraleGte60, -1,
captured0: root); // capture root for trigger re-evaluation
ctx.FlushCommands();
}

Example is normative for the committed lowering shape. GetChildren signature at src/SECS.Abstractions/Interfaces/ISecsHostReads.cs:10.

Why this shape: Collections are host-owned relationship tables (the host knows which entities belong to which parent — the engine does not). GetChildren is a read-only slice into that relationship data. The ReadOnlySpan<EntityHandle> return is zero-alloc: the host can back the span with a pooled array or a struct-of-arrays slice. The for (int i = 0; i < .Length; i++) form is preferred over foreach (var x in span) because Span<T>-over-foreach emits the same IL, and explicit indexing makes the scope-stack capture lowering (captured0: root, captured1: prev) direct and reviewable.

Scope-stack capture: Inside a foreach child in parent.Collection, the compiler pushes one scope stack frame. @prev becomes parent; @this becomes child. If an add_modifier ... while E inside the body references @root, @prev, or another outer scope, the compiler emits captured0: root and/or captured1: prev at the add_modifier call site so the trigger delegate can reach those entities from the binding's stored state. A required capture that is Null makes the trigger false; it must not silently fall back to the binding target.

Notes: None.

Generated command flush contract

What: The compiler-emitted write barrier for generated behavior bodies. Not present in .secs source; the compiler inserts a flush after every command-producing source statement in systems, events, and activity lifecycle methods.

SECS: (no keyword — the pattern is implicit in how the compiler lowers command-producing statements).

Compiles to (this example shows the helper after sequential writes):

foreach (var settlement in ctx.InstanceStore.AllByContract(H.SettlementContract))
{
// ... compute totalFood etc. ...

if (totalFood != 0) {
ctx.Commands.IncrementScopeField(settlement, H.Settlement, H.Food_Channel, totalFood);
ctx.FlushCommands();
}
if (totalWood != 0) {
ctx.Commands.IncrementScopeField(settlement, H.Settlement, H.Wood, totalWood);
ctx.FlushCommands();
}
// ...
}

Source: examples/valenar/Generated/Systems/Economy/ResourceProductionSystem.cs:64-90.

Why this shape: The CommandBuffer is a single per-TickContext list (see src/SECS.Abstractions/Commands/CommandBuffer.cs:3-20). ctx.FlushCommands() calls the runtime processor and clears that shared buffer even if processing throws. Flushing after each command-producing source statement keeps the buffer bounded, preserves deterministic statement order, and means later statements and later loop iterations see earlier writes. Batching across a whole event fire, activity lifecycle method, system execution, or tick would defer writes and break ordinary C#-style sequential semantics.

Read-after-write guarantee: After a command-producing source statement completes, generated code flushes before the next source statement. A later raw field read, channel resolve, trigger check, event subscriber, activity lifecycle callback, or loop iteration sees all writes flushed by earlier statements in the same tick. There is no .secs syntax to defer or opt out of this flush.

Boundary safety: Runtime dispatchers also flush on normal return from a generated template method, system, event Execute, event option handler, and activity lifecycle method. If a body exits by exception before reaching its emitted flush, the dispatcher discards the still-pending buffer before rethrowing so commands cannot leak into another template method, event, activity, or system. Already-flushed commands are not rolled back.

Template methods: Generated contract method void bodies receive ITemplateCommandContext. They queue into context.Commands, call context.FlushCommands() after each command-producing source statement, and still get a runtime boundary flush as a safety net. TemplateActivator and TickDispatcher validate each template-method flush so contract methods cannot register template intrinsic channel sources; those remain activation-only. Later statements in the same template method therefore get the same read-after-write guarantee as systems, events, and activities.

Notes: None.

Query keywords

Query keywords are sugar over foreach + if. They lower to the same for-loop shape as a foreach, with three variations: short-circuit (any, first), visit-all (every, count), and collect-then-pick (random). All five forms also support both iteration sources — contract iteration (any X in Contract ...) and collection iteration (any X in parent.Collection ...).

No Valenar .secs file uses any query keyword in its current content. The query lowerings below are therefore normative live-contract examples: when content starts using these forms, it must lower exactly to these shapes.

any ... in ... where E — existence check

What: Returns bool — true if any entity in the iteration source matches E. Short-circuits on first match.

SECS:

if any location in province.Locations
where location.resolve(Defense) > 30
{
settlement.increment(Morale, 2);
}

Example is normative for the committed surface; no current Valenar source uses this form yet.

Compiles to:

bool anyDefended = false;
var locations = ctx.Host.GetChildren(province, H.Province_Locations);
for (int i = 0; i < locations.Length; i++)
{
if (ctx.ChannelResolver.ResolveIntFast(locations[i], H.Defense) > 30)
{
anyDefended = true;
break; // guaranteed short-circuit
}
}
if (anyDefended)
{
ctx.Commands.IncrementScopeField(settlement, H.Settlement, H.Morale, 2);
ctx.FlushCommands();
}

Example is normative for the committed lowering shape.

Why this shape: any is the same shape as a hand-written foreach + break except the compiler guarantees the short-circuit. No reason to allocate anything; the local bool is the only state.

Notes: No Generated/ reference exists yet — this section is the exact live contract for the shape.

every ... in ... where E { ... } — filtered iteration

What: Visits every entity in the iteration source where E holds; the body executes once per match. Does not short-circuit. Returns void.

SECS:

every location in province.Locations
where location.resolve(FoodOutput) > 20
{
location.add_modifier BountifulHarvest
owned_by @Settlement
while @Settlement.resolve(Morale) >= 60 duration 5;
}

Example is normative for the committed surface; no current Valenar source uses this form yet.

Compiles to:

var locations = ctx.Host.GetChildren(province, H.Province_Locations);
for (int i = 0; i < locations.Length; i++)
{
var location = locations[i];
if (ctx.ChannelResolver.ResolveIntFast(location, H.FoodOutput) <= 20)
continue;

ctx.Commands.AddModifier(settlement, location, H.BountifulHarvest,
H.Trigger_MoraleGte60, 5, captured0: root);
ctx.FlushCommands();
}

Example is normative for the committed lowering shape.

Why this shape: Literally a foreach + if + continue — the keyword is pure syntactic sugar. The owned_by @Settlement clause makes the settlement the lifetime owner while the receiver location stays the effect target. captured0: root appears because the while trigger reads settlement Morale from the outer scope, while the binding target is location. The captured entity is only a predicate reference; ownership still comes from owned_by.

Notes: None.

count ... in ... where E — filtered count

What: Returns int — the number of entities in the iteration source matching E. Visits every entity (no short-circuit).

SECS:

var weakLocations = count location in province.Locations
where location.resolve(FoodOutput) < 5;

if (weakLocations > 2)
{
@Settlement.add_modifier Famine duration 5;
}

Example is normative for the committed surface; no current Valenar source uses this form yet.

Compiles to:

int weakLocations = 0;
var locations = ctx.Host.GetChildren(province, H.Province_Locations);
for (int i = 0; i < locations.Length; i++)
{
if (ctx.ChannelResolver.ResolveIntFast(locations[i], H.FoodOutput) < 5)
weakLocations++;
}
if (weakLocations > 2)
{
ctx.Commands.AddModifier(settlement, settlement, H.Famine, 0, 5);
ctx.FlushCommands();
}

Example is normative for the committed lowering shape.

Why this shape: A local int counter + the same foreach the compiler already knows how to emit. No allocation, no boxing.

Notes: No Generated/ reference.

random ... in ... where E save_scope_as name — pick random match

What: Collects every matching entity into a stackalloc'd span of indices, then uses ctx.Random.Next to pick one. Saves the pick as a scope via save_scope_as.

SECS:

random location in province.Locations
where location.resolve(FoodOutput) > 10
save_scope_as best_farm;

if (scope:best_farm != null)
{
scope:best_farm.add_modifier BountifulHarvest owned_by @Settlement duration 3;
}

Example is normative for the committed surface; no current Valenar source uses this form yet.

Compiles to:

var locations = ctx.Host.GetChildren(province, H.Province_Locations);
int matchCount = 0;
Span<int> matchIndices = stackalloc int[locations.Length];
for (int i = 0; i < locations.Length; i++)
{
if (ctx.ChannelResolver.ResolveIntFast(locations[i], H.FoodOutput) > 10)
matchIndices[matchCount++] = i;
}
if (matchCount > 0)
{
var picked = locations[matchIndices[ctx.Random.Next(matchCount)]];
ctx.SaveScope(H.BestFarm, picked);
}

Example is normative for the committed lowering shape.

Why this shape: stackalloc int[N] allocates on the call stack — no heap allocation. matchCount tracks how many entries are live in the span. ctx.Random.Next(matchCount) is the deterministic RNG seeded per-game (see TickContext.Random at src/SECS.Engine/Pipeline/TickContext.cs:26). The alternative (allocating a List<EntityHandle>) would heap-allocate ~64 bytes per call per settlement — the stackalloc approach is zero-alloc.

Size bound: The stackalloc size is bounded by the iteration source's Length. For contract iteration (InstanceStore.AllByContract), the compiler must emit a safe upper bound or materialize the iteration into a span first; for collection iteration (GetChildren), .Length is known statically. When contract iteration count is unbounded at the call site, the compiler emits a heap-alloc fallback. The exact threshold is an implementation detail shared by the compiler's stackalloc heuristic.

Notes: No Generated/ reference. Unbounded-contract stackalloc fallback threshold not specified.

first ... in ... where E save_scope_as name — first match

What: Returns the first entity in iteration order where E holds. Short-circuits. Saves via save_scope_as.

SECS:

first location in province.Locations
where location.resolve(Garrison) < 5
save_scope_as weakest;

if (scope:weakest != null)
{
scope:weakest.increment(Garrison, 5);
}

Example is normative for the committed surface; no current Valenar source uses this form yet.

Compiles to:

EntityHandle firstWeak = EntityHandle.Null;
var locations = ctx.Host.GetChildren(province, H.Province_Locations);
for (int i = 0; i < locations.Length; i++)
{
if (ctx.ChannelResolver.ResolveIntFast(locations[i], H.Garrison) < 5)
{
firstWeak = locations[i];
break; // short-circuit: found the first match
}
}
if (!firstWeak.IsNull)
{
ctx.Commands.IncrementScopeField(firstWeak, H.Location, H.Garrison, 5);
ctx.FlushCommands();
}
ctx.SaveScope(H.Weakest, firstWeak); // may be Null — caller's if-condition handles it

Example is normative for the committed lowering shape. The explicit SaveScope call is shown because save_scope_as lowers to that runtime write.

Why this shape: A local EntityHandle + the same short-circuit foreach as any, with the matched entity captured before the break. Saving even when no match was found (writing EntityHandle.Null) is consistent with how scope:X != null conditions read.

Notes: No Generated/ reference.

Cross-reference: iteration source syntaxes

Both foreach and the five query keywords accept the same two iteration-source shapes:

ShapeLowering targetReturns
X in Contractctx.InstanceStore.AllByContract(H.XContract)zero-alloc ref-struct enum
X in Contract (tick-dist.).AllByContractForTick(H.XContract, tn, N)filtered by (id+tn)%N == 0
X in parent.Collectionhost.GetChildren(parent, H.Collection)ReadOnlySpan<EntityHandle>

The compiler disambiguates by looking up the identifier: a bare Settlement is a scope/contract name (from the scope declarations); a dotted province.Locations is a collection name (from the parent scope's collection list — see doc 01).

Mod scope note (queries and the merged scope graph): any, every, count, random, and first lower to the same GetChildren / AllByContract calls as foreach, so they pick up every mod-added entity in the iteration source identically. For collection iteration (any shrine in settlement.Shrines), the host's GetChildren implementation must recognise the collection identifier — if Shrines is a mod-added collection on the base Settlement scope, the host bridge must expose it. The mechanism for mods to add scope collections to existing base scopes is DEFERRED (01-world-shape.md open question on scope extension); until it ships, mods may add collections only on mod-declared scopes. Effect primitives invoked inside query bodies (add_modifier, fire, increment) resolve their identifiers across the merged set the same way they do in foreach bodies — see the add_modifier mod scope note above.

End-to-end example: TaxCollectionSystem.Execute

This is every expression form in one system body. On the left, the .secs source; on the right, the compiled C#. Each line maps to a construct covered above.

.secs (canonical update of examples/valenar/Content/economy/systems/tax_collection.secs:1-18):

// Tax collection system — moddable via .secs mod operations.
// Every month (30 ticks), every settlement collects 1 gold per person.

system TaxCollection
{
phase = Phases.Production;
frequency = Cadence.Monthly; // = 30 ticks

method void Execute()
{
foreach settlement in Settlement
{
var population = settlement.resolve(Population);
var income = population * 1;
settlement.increment(Gold, income);
}
}
}

Compiled C# (current typed shape from examples/valenar/Generated/Systems/Economy/TaxCollectionSystem.cs:11-39):

public sealed class TaxCollectionSystem : ITickSystem
{
public static readonly SystemRegistration Registration = new()
{
System = new TaxCollectionSystem(),
Name = "Tax Collection",
Source = SystemSource.Secs,
};

private const int DistributedFrequency = 30; // monthly = every 30 ticks

public TickRateId Frequency => Cadence.Daily.Id; // runs every tick; per-entity gating handles distribution
public PhaseId Phase => Phases.Production.Id;

public void Execute(TickContext ctx)
{
foreach (var settlement in ctx.InstanceStore.AllByContractForTick(
H.SettlementContract, ctx.TickNumber, DistributedFrequency))
{
var population = ctx.ChannelResolver.ResolveIntFast(settlement, H.Population);
var taxPerPerson = 1;
var income = population * taxPerPerson;

ctx.Commands.IncrementScopeField(settlement, H.Settlement, H.Gold, income);
ctx.FlushCommands();
}
}
}

Construct-by-construct mapping:

.secs tokenLowered toDoc section
system TaxCollection { ... }public sealed class TaxCollectionSystem : ITickSystemdoc 04 — systems
phase = Phases.Production;public PhaseId Phase => Phases.Production.Id;doc 04 — systems
frequency = Cadence.Monthly;DistributedFrequency = 30; Frequency => Cadence.Daily.Id; AllByContractForTick(...)doc 04 — systems
method void Execute() { ... }public void Execute(TickContext ctx) { ... }doc 04 — systems
foreach settlement in Settlementforeach (var settlement in ctx.InstanceStore.AllByContractForTick(...))05 — foreach in Contract
settlement.resolve(Population)ctx.ChannelResolver.ResolveIntFast(settlement, H.Population)05 — scope.resolve(Channel)
settlement.increment(Gold, income)ctx.Commands.IncrementScopeField(settlement, H.Settlement, H.Gold, income)05 — scope.increment(...)
(implicit command flush)ctx.FlushCommands();05 — Generated command flush contract

Every token accounted for. No surprises in the lowering — the compiler's job on this file is almost mechanical.

Cross-references

  • Target types — ISecsHostReads, ISecsHostWrites, CommandBuffer, TickContext — see this document's prerequisites plus the lowering examples above.
  • Modifier declarations referenced by add_modifier (including ReApplyMode, tags, effect bodies): 03-channels-and-modifiers.md.
  • Event and on-action declarations referenced by fire, plus on-action provides scope:X validation: 04-behavior.md.
  • Scope stack semantics (this, root, prev rules, push/pop on foreach): this document's Built-in sigils, save_scope_as, and Iteration sections.
  • Query keyword semantics (short-circuit vs visit-all, stackalloc bounds, integration with saved scopes): this document's Query keywords section.
  • Hash generation — H.* values are FNV-1a-64 hashes of canonical id strings, with tag mirrors equal to the matching TagId.Value; see docs/design/01-world-shape.md § "Canonical id string format" and examples/valenar/Generated/Hashes.cs.