04 — Behavior
Systems, events, on-actions, and activities are the four SECS constructs that do things rather than describe things. They all feed into the same tick pipeline and share one dispatcher, but they diverge on who triggers them: systems run on the scheduler, pulse events run on the scheduler-plus-RNG, on-action events run when something else fires them, and activities run when a player or AI picks them. This doc covers all four together because reading about any one in isolation misses the dispatch model that binds them.
SECS stands for Scripting Engine C Sharp.
Prerequisites
00-overview.md— the 5-section skeleton and doc map.01-world-shape.md—scope,contract, and top-levelchanneldeclarations, because systems iterate contracts and events target scopes.02-templates.md— intrinsic channel sources, contract methods, and lifecycle bindings, becausecreate_entity(insideMapGeneration) activates templates and events read resolved channels.
Some expression-level features used inside bodies — scope:name, save_scope_as, scope.resolve(...), foreach ... in Contract, add_modifier — are previewed here but fully specified in 05-expressions.md. When a code block uses one of them, it is called out as a preview.
Systems
system Name { phase = ... / frequency = ... / Execute } — basic shell
What: A per-tick procedure that iterates some contract's entities and mutates them via TickContext.Commands. Non-static. Lowers to a class implementing ITickSystem.
SECS:
system ResourceProduction
{
phase = Phases.Production;
frequency = Cadence.Daily;
method void Execute()
{
foreach settlement in Settlement
{
// ... body ...
}
}
}
Compiles to:
public sealed class ResourceProductionSystem : ITickSystem
{
public static readonly SystemRegistration Registration = new()
{
System = new ResourceProductionSystem(),
Name = "Resource Production",
Source = SystemSource.Secs,
};
public TickRateId Frequency => Cadence.Daily.Id;
public PhaseId Phase => Phases.Production.Id;
public void Execute(TickContext ctx)
{
foreach (var settlement in ctx.InstanceStore.AllByContract(H.SettlementContract))
{
// ... body ...
}
}
}
Current typed shape from examples/valenar/Generated/Systems/Economy/ResourceProductionSystem.cs.
Why this shape: The NameSystem class suffix is fixed — every system lowers to {Name}System. A single nested Execute(TickContext) keeps every system callable from the same pipeline step without reflection. SystemRegistration is a public static readonly field on the class, so SecsModule.Systems can reference it by name (ResourceProductionSystem.Registration) with no container collection logic. Source = SystemSource.Secs marks the system as mod-operation targetable; host-side systems use SystemSource.Host and are excluded from the merge pass.
Notes: None.
Phase vocabulary
What: phase = Phases.Production; assigns the system's system_phase slot. Phases.Production is a plain C# PhaseDeclaration value; generated systems expose its strongly typed PhaseId.
SECS:
system TaxCollection
{
phase = Phases.Production;
frequency = Cadence.Monthly;
method void Execute() { /* ... */ }
}
Compiles to:
public sealed class TaxCollectionSystem : ITickSystem
{
public PhaseId Phase => Phases.Production.Id;
// ...
}
The phase vocabulary itself is plain C# inside .secs content:
public static class Phases
{
public static readonly PhaseDeclaration Production =
PhaseDeclaration.Create("valenar:phase/production", SystemPhase.Main, 1);
}
Why this shape: Phase names are game vocabulary, not SECS keywords. The compiler recognizes the phase = ...; slot and type-checks that the expression is a PhaseDeclaration; the engine receives the resolved PhaseId, then looks up stage/order from the registered phase declarations. Host-capable expansions can add phase declarations because they can also update scheduler configuration. Data-only mods may reassign systems to existing phases via inject system X { phase = Phases.Maintenance; }, but arbitrary new phase ordering is still host-owned. A mod.json phase_order mechanism — [{name: "Harvest", after: "Growth"}] with topological-sort merging — is DEFERRED; until it ships, mods that need a new ordered phase must coordinate with the host or use an existing phase.
Notes:
mod.json phase_ordersyntax for declaring new ordered phases. Deferred until a mod use case exists; today the base host owns phase ordering exclusively.
Frequency gating and load distribution (Cadence.Daily / Cadence.Monthly / TickRateId.Once)
What: frequency = Cadence.Daily; runs every tick. frequency = Cadence.Monthly; runs logically every 30 ticks, lowered as a per-entity distribution: the system's Frequency exposes a typed daily cadence (Cadence.Daily.Id) so the system is scheduled every tick, but the body iterates via AllByContractForTick(..., ctx.TickNumber, 30), which spreads the 30-tick-interval work across 30 consecutive ticks — each entity processed on exactly one tick per 30-tick window. One-shot systems expose TickRateId.Once, which runs the system exactly once on its first scheduled tick and then deactivates it.
SECS:
system TaxCollection
{
phase = Phases.Production;
frequency = Cadence.Monthly;
method void Execute()
{
foreach settlement in Settlement
{
var population = settlement.resolve(Population);
settlement.increment(Gold, population * 1);
}
}
}
Compiles to:
public sealed class TaxCollectionSystem : ITickSystem
{
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 income = population * 1;
ctx.Commands.IncrementScopeField(settlement, H.Settlement, H.Gold, income);
ctx.FlushCommands();
}
}
}
Current typed shape from examples/valenar/Generated/Systems/Economy/TaxCollectionSystem.cs.
For one-shot map generation:
public sealed class MapGenerationSystem : ITickSystem
{
public TickRateId Frequency => TickRateId.Once;
public PhaseId Phase => Phases.Production.Id;
public void Execute(TickContext ctx) { /* ... */ }
}
Current typed shape from examples/valenar/Generated/Systems/World/MapGenerationSystem.cs.
Why this shape: Two things are happening at once and must be distinguished. First, ITickSystem.Frequency is the pipeline-level cadence — the pipeline resolves the typed TickRateId against the registered tick-rate table, with TickRateId.Once treated specially as "first scheduled tick only". Second, load distribution inside a monthly body is a per-entity concern — if 300 settlements all did their monthly tax on the same tick, that tick would spike. The lowering keeps the system scheduled daily and pushes distribution into the iterator (AllByContractForTick), which filters entities to those whose (entityId + tickNumber) % 30 == 0. Over any 30-tick window, every settlement is processed exactly once; no tick pays the full monthly cost. The author writes frequency = Cadence.Monthly; and gets distribution for free.
The Cadence.Daily case is the trivial case: no DistributedFrequency constant, just AllByContract (the non-distributing iterator). ResourceProductionSystem demonstrates this.
The one-shot case uses a distinct typed sentinel: TickRateId.Once. The pipeline maps that id to the legacy internal SystemFrequency.Once gate and runs the system exactly on its first scheduled tick, then marks it inactive. This is the correct shape for map generation and one-shot initialization; do not try to encode it by setting an enormous tick count.
Valenar's current new-game path follows that rule: GameRuntime.Create builds
the engine/runtime and drives the first tick, but MapGenerationSystem is the
SECS-generated system that imports WorldData and creates MainCharacter with
ctx.CreateEntity(new TemplateId(H.MainCharacter), ...). The host boot path is
the scheduler driver, not the author of the player character. If the project
later adds a player-facing "new game" event or setup activity, it should call
into the same registered system/activity/event surfaces instead of moving
MainCharacter creation back into ad hoc host code.
Why not distribute false for opt-out of load distribution: .secs does not have that syntax yet. There is no frequency = Cadence.Monthly distribute false or equivalent — every monthly system gets load distribution, and every daily system runs everyone every tick. NOT YET IMPLEMENTED. If a use case arises where a monthly system must process all entities on the same tick (e.g., a monthly global recalculation), that opt-out will need its own syntax.
Notes: Should arbitrary tick-rate values be declared only through game-level TickRate vocabulary (Cadence.Fortnightly = TickRate.Create(...)), or should frequency = 14; become a direct system-slot expression? The engine can run any registered positive tick period; Valenar has not exercised direct integer syntax.
SystemRegistration
What: Each system exposes a public static readonly SystemRegistration Registration field. Generated/SecsModule.cs aggregates these into a single SystemRegistration[] Systems array that the host passes to the registry.
SECS: No direct surface syntax — the author writes system Name { ... } and the registration is emitted automatically.
Compiles to:
// Inside each system class
public static readonly SystemRegistration Registration = new()
{
System = new TaxCollectionSystem(),
Name = "Tax Collection",
Source = SystemSource.Secs,
};
// In Generated/SecsModule.cs
public static readonly SystemRegistration[] Systems =
[
MapGenerationSystem.Registration,
ResourceProductionSystem.Registration,
TaxCollectionSystem.Registration,
PopulationGrowthSystem.Registration,
];
From examples/valenar/Generated/SecsModule.cs lines 128-134.
Why this shape: The registration struct is owned by the system class, not by SecsModule, so adding a new system does not edit SecsModule declaration-by-declaration — it adds one class file and one line to the array. The Name is the display name for UI and profiling (Tax Collection with a space, distinct from the generated class name TaxCollectionSystem). Source distinguishes Secs systems, whose compiler-owned slots may be targeted by inject system / replace system, from Host systems, which are host-registered and not touchable by mods. The current Valenar generated set emits only SystemSource.Secs; host-side systems (MapGenerationConstants, MapReadySystem, etc. under Host/Systems/) register themselves with SystemSource.Host from host code, not from SecsModule.
Mod scope note: SystemSource is a closed two-value enum (Secs, Host); mods cannot extend it. Every mod-declared system lowers with Source = SystemSource.Secs regardless of which mod authored it. There is no way for a mod to declare a SystemSource.Host system from .secs; host-protected systems are expressible only in host C# code. A mod that wants to move a SECS system into a host-declared phase writes inject system X { phase = Phases.Y; } and accepts that later mods may write the same system_phase slot by load order. See 06-overrides-and-modding.md § "The merge-pass pipeline" for how the merger applies system slot writes across base and mod source sets.
Notes: None.
End-to-end: TaxCollectionSystem
What: The canonical example — monthly frequency, Production phase, per-entity load distribution, commands-plus-processor pattern for mutation.
SECS: Canonical update of examples/valenar/Content/economy/systems/tax_collection.secs:
// 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;
method void Execute()
{
foreach settlement in Settlement
{
var population = settlement.resolve(Population);
var income = population * 1;
settlement.increment(Gold, income);
}
}
}
Compiles to:
// Source: Content/economy/systems/tax_collection.secs
namespace Valenar.Generated.Systems;
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();
}
}
}
Current typed shape from examples/valenar/Generated/Systems/Economy/TaxCollectionSystem.cs.
Why this shape: Three choices compound into the canonical body pattern. (1) settlement.resolve(Population) lowers to ctx.ChannelResolver.ResolveIntFast(settlement, H.Population) — every channel read runs through the channel resolution pipeline, never reads host fields directly. (2) settlement.increment(Gold, income) lowers to ctx.Commands.IncrementScopeField(settlement, H.Settlement, H.Gold, income), which queues a command rather than writing immediately. (3) ctx.FlushCommands() applies queued commands synchronously and clears the shared buffer. The statement-level flush trades a tiny amount of dispatch overhead for the guarantee that a command's side effects are visible to the next statement and to the next loop iteration — if you deferred all commands to end-of-system, later reads would see stale host fields and stale invalidation state.
Authors write .increment(Gold, income) and get the full queue-flush sequence emitted for them. The compiler is not performing coalescing across source statements; the generated C# preserves sequential source semantics.
Notes: None for command flushing. The compiler-emitted rule is statement-level FlushCommands() in generated system/event/activity bodies, with runtime boundary guards as a safety net.
Events
event Name — basic pulse event
What: A conditional effect fired by the scheduler on a cadence, with a chance roll and a condition check, against every entity of some contract. Non-static. Lowers to a class derived from SecsEvent plus an EventEntry registration.
SECS:
event DemonRaid
{
trigger pulse;
frequency Daily;
chance 2;
query bool Condition()
{
return @Settlement.resolve(Defense) < 30;
}
method void Execute()
{
@Settlement.add_modifier DemonDread duration 3;
}
}
Compiles to:
public sealed class DemonRaidEvent : SecsEvent
{
public static readonly DemonRaidEvent Instance = new();
public static readonly EventEntry Registration = new()
{
EventId = H.DemonRaid,
Name = "Demon Raid",
TriggerType = EventTriggerType.Pulse,
ContractId = H.SettlementContract,
Event = Instance,
};
public override int Frequency => 1; // Daily
public override int Chance => 2; // 2%
public override bool Condition(EventContext context)
{
var scope = context.Scope;
var ctx = context.Tick;
return ctx.ChannelResolver.ResolveIntFast(scope.Owner, H.Defense) < 30;
}
public override void Execute(EventContext context) { /* body */ }
}
Verbatim shape from examples/valenar/Generated/Events/Combat/Raids/DemonRaidEvent.cs.
Why this shape: Events are non-static — each declaration lowers to one reusable event object registered once. This keeps an open door for event-owned cached data or campaign-local state, though Valenar does not yet exercise that. Active per-fire state does not live on the event object; it lives in EventContext, so pulse execution and deferred player choices can recreate the same frame cleanly. {Name}Event is the fixed class-name suffix. EventEntry is a separate plain-data struct because it carries dispatch metadata (EventId, TriggerType, ContractId, OnActionId, Priority, Weight) that is separate from method bodies. Like template methods, event source bodies receive world context through an implicit scope frame: source writes method void Execute() and reads @Settlement; generated C# receives EventContext context, then reads context.Scope and context.Tick.
Notes: None.
Trigger types (pulse vs on_action)
What: trigger pulse lowers to TriggerType = EventTriggerType.Pulse — the event is checked each tick on every matching entity. trigger <on_action_name> lowers to TriggerType = EventTriggerType.OnAction plus an OnActionId field — the event is inert until something fires that on-action.
SECS:
event DemonRaid
{
trigger pulse;
// ...
}
event CelebrationBonus
{
trigger on_building_complete;
// ...
}
event RaidResolvedDefeat
{
trigger on_raid_resolved;
weight = 50;
// ...
}
Compiles to:
// Pulse
public static readonly EventEntry Registration = new()
{
EventId = H.DemonRaid,
Name = "Demon Raid",
TriggerType = EventTriggerType.Pulse,
ContractId = H.SettlementContract,
Event = new DemonRaidEvent(),
};
// On-action
public static readonly EventEntry Registration = new()
{
EventId = H.CelebrationBonus,
Name = "Celebration",
TriggerType = EventTriggerType.OnAction,
ContractId = H.SettlementContract,
OnActionId = H.OnAction_BuildingComplete,
Event = new CelebrationBonusEvent(),
};
// On-action, weighted
public static readonly EventEntry Registration = new()
{
EventId = H.RaidResolvedDefeat,
Name = "Post-Raid: Plague Outbreak",
TriggerType = EventTriggerType.OnAction,
ContractId = H.SettlementContract,
OnActionId = H.OnAction_RaidResolved,
Event = new RaidResolvedDefeatEvent(),
Weight = 50,
};
From Generated/Events/Combat/Raids/DemonRaidEvent.cs, CelebrationBonusEvent.cs, RaidResolvedDefeatEvent.cs.
Why this shape: The compiler decides at declaration which list an event goes into — pulseEvents or onActionEvents[OnActionId] — by inspecting the trigger keyword. The dispatcher holds each list separately (EventDispatcher.pulseEvents, EventDispatcher.onActionEvents in src/SECS.Engine/Events/EventDispatcher.cs), so an on-action event pays zero cost per tick when nothing fires its on-action. The Priority field (default 0) governs firing order within an on-action's subscriber list; the Weight field (default 100) only matters in WeightedRandom selection mode.
Mod scope note: trigger <on_action_name> resolves against the merged on-action set produced by the merge pass, not against the source set the event itself was declared in. An event in mod B can subscribe to an on-action declared in mod A or in the base, and an event in the base can subscribe to a mod-declared on-action — what matters is that the on-action is present in the merged set when the binder runs. Cross-mod subscriptions therefore require correct load order: the on-action must be declared by a source set processed at or before the subscribing event's source set. The launcher's mod.json load_after mechanism (see 06-overrides-and-modding.md § "Load order and last-writer-wins") is responsible for ensuring this. Unresolved triggers — trigger foo where no on_action foo exists in the merged set — emit a binder diagnostic (proposed SECS0402) pointing at the subscribing event's trigger line.
Notes:
- Diagnostic code for unresolved on-action references (
SECS0402proposed). Awaiting Phase 3 binder implementation.
chance and Condition()
What: chance N lowers to Chance => N. query bool Condition() { ... } lowers to a bool Condition(EventContext context) override. Both are evaluated per-target by the dispatcher; an event fires only when the chance roll succeeds AND the condition returns true.
SECS:
event DemonRaid
{
trigger pulse;
frequency Daily;
chance 2;
query bool Condition()
{
return @Settlement.resolve(Defense) < 30;
}
// ...
}
Compiles to:
public override int Frequency => 1;
public override int Chance => 2;
public override bool Condition(EventContext context)
{
var scope = context.Scope;
var ctx = context.Tick;
return ctx.ChannelResolver.ResolveIntFast(scope.Owner, H.Defense) < 30;
}
Why this shape: Frequency, Chance, and Condition correspond 1:1 to the three filter stages in EventDispatcher.TickPulse: first a modulo check on TickNumber, then an RNG roll, then the condition. Each stage short-circuits, cheapest check first. settlement is the event target's scope sigil; for a settlement-rooted event it lowers to scope.Owner / scope.Root. If the author uses any other saved scope (scope:Location, scope:WasVictory), the compiler emits a ctx.GetSavedScope(H.Foo) call instead (see RaidResolvedDefeatEvent).
Event conditions also run on on-action-triggered events (e.g., FamineStartsEvent.Condition checks Population > 5). For on-action events there is no chance / frequency — the dispatcher filters only on Condition.
Mod scope note: Each event-declaration sub-clause or method — trigger, frequency, chance, Condition, Execute, BuildOptions, individual option handler methods, priority, weight — is an independently addressable slot under the mod-operation merger. A mod may write inject event DemonRaid { query bool Condition() { return @Settlement.resolve(Defense) < 50; } } to change only the threshold; the rest of the base event body (frequency, chance, execute body) is retained. Slot enumeration and the per-slot conflict-report shape are specified in 06-overrides-and-modding.md § "Conflict detection and reporting".
Notes: None.
Player-choice events (BuildOptions and option handlers)
What: An event that implements BuildOptions(EventOptions options) does not auto-execute when that builder adds one or more options. Instead the dispatcher calls BuildOptions for the current fire, snapshots the resulting EventOption list, and parks a PendingChoice for the player UI to resolve.
SECS:
event PlagueSpreads
{
trigger pulse;
frequency Monthly;
chance 5;
query bool Condition()
{
return @Settlement.resolve(Population) > 20;
}
method void BuildOptions(EventOptions options)
{
options.Add("Quarantine", Quarantine);
options.Add("Ignore", Ignore);
}
method void Quarantine()
{
@Settlement.add_modifier QuarantinePenalty duration 10;
@Settlement.increment(Morale, -10);
}
method void Ignore()
{
@Settlement.add_modifier PlagueSickness duration 15;
}
}
Compiles to:
public sealed class PlagueSpreadsEvent : SecsEvent
{
public static readonly PlagueSpreadsEvent Instance = new();
public static readonly EventEntry Registration = new()
{
EventId = H.PlagueSpreads,
Name = "Plague Spreads",
TriggerType = EventTriggerType.Pulse,
ContractId = H.SettlementContract,
Event = Instance,
};
public override int Frequency => 30; // Monthly
public override int Chance => 5; // 5%
public override bool Condition(EventContext context)
{
var scope = context.Scope;
var ctx = context.Tick;
return ctx.ChannelResolver.ResolveIntFast(scope.Owner, H.Population) > 20;
}
public override void BuildOptions(EventOptions options, EventContext context)
{
options.Add(
"Quarantine",
"Restrict movement. Morale drops but plague is contained.",
Quarantine);
options.Add(
"Ignore",
"Let it spread. Risk losing population.",
Ignore);
}
private static void Quarantine(EventContext context)
{
var scope = context.Scope;
var ctx = context.Tick;
ctx.Commands.AddModifier(scope.Owner, scope.Owner, H.QuarantinePenalty, 0, 10);
ctx.FlushCommands();
ctx.Commands.IncrementScopeField(scope.Owner, H.Settlement, H.Morale, -10);
ctx.FlushCommands();
}
private static void Ignore(EventContext context)
{
var scope = context.Scope;
var ctx = context.Tick;
ctx.Commands.AddModifier(scope.Owner, scope.Owner, H.PlagueSickness, 0, 15);
ctx.FlushCommands();
}
}
Verbatim from examples/valenar/Generated/Events/Settlement/Crisis/PlagueSpreadsEvent.cs.
Why this shape: The source surface stays C#-shaped inside explicit query / method bodies: options are methods, and BuildOptions registers explicit display names plus method groups. The compiler does not infer option names through reflection; "Quarantine" lowers directly to EventOption.Name, and Quarantine lowers to the option's execute delegate. The dispatcher distinguishes auto-firing events from player-choice events by the builder result: it calls BuildOptions, and if EventOptions.Count > 0, it queues a PendingChoice carrying the EventEntry, target, built options, and a saved-scope snapshot instead of calling Execute. The UI reads the choice, the host calls back into the dispatcher with the picked option index, and the dispatcher recreates an EventContext and invokes the selected option handler.
Pending choices are projected to the UI but are not persisted today; option identity is index-based until the future stable-key protocol lands.
Description strings for the options do not currently come from .secs source — the compiler will need a committed localization contract for option descriptions. Today they are hand-written. NOT YET IMPLEMENTED in the compiler; the Generated/ stand-in fills them manually.
Each option handler's body lowers to a method that receives EventContext. The source handler itself does not take target or ctx; it runs in the event's scope frame just like Execute().
Mod scope note (option overrides): Each registered option handler under an event is an independently addressable slot keyed by the explicit string name passed to options.Add. A mod may:
- Add a new option by declaring a new handler method and registering it from
BuildOptions, e.g.method void Prayer() { ... }plusoptions.Add("Prayer", Prayer);—Prayerdoes not exist in base, so the merger appends a newoptions.Add(...)call to the merged builder. The built option list grows. - Replace an existing option by writing the handler method slot, e.g.
method void Quarantine() { ... }—Quarantineis in base, so the merger writes the mod operation's body over the base body. Other handlers on the same event (Ignore) are untouched. - Removal of a base option is NOT YET SPECIFIED — there is no
options.Remove("Quarantine")syntax in the mod-operation grammar. A mod that wants to suppress a base option today must replaceBuildOptionsand omit the registration, which is full-replacement and conflicts with finer-grained mods.
Option descriptions inherit the localization gap (see below); a mod-introduced option ships its description text the same way base options do. Until the localization story lands, mod authors must hand-write description strings in their mod's Generated/ stand-in.
Notes:
- Option descriptions: pulled from a localization key derived from the option name (
event.plague_spreads.quarantine.desc) or written inline in.secs? Today they are hand-filled in Generated/. Needs a spec call. Mod-introduced options inherit this gap; resolving it must include the mod-localization directory convention. - Timeout / auto-pick: if the player never resolves a pending choice, does the event expire, auto-pick the first option, or block further pulses of the same event? Not yet specified.
- Option removal through mod operations:
options.Remove("Name"), replacement-onlyBuildOptions, or another method-based shape? Deferred until a mod use case demands removal.
Event registration (EventEntry shape)
What: Every event emits a public static readonly EventEntry Registration field. Generated/SecsModule.cs aggregates these into EventEntry[] Events.
SECS: No direct syntax. Entry fields derive from the event declaration:
EventId: FNV-1a-64 hash of the event's canonical id string →H.{EventName}.Name: human-readable display name (defaulted from identifier; today it is hand-written in the generated stand-in).TriggerType:PulseorOnActionbased ontriggerkeyword.ContractId: hash of the `` sigil scope in the body (Valenar events all targetSettlement).Event: the event singleton, usually{Name}Event.Instance.OnActionId: set only forTriggerType == OnAction. Matches the on-action's declared ID.Priority: optional, default 0. Event declarations may carry aprioritykeyword; Valenar does not currently exercise it.Weight: optional, default 100. Used byWeightedRandomon-actions. Set from theweight = Nkeyword in an on-action-triggered event.
Compiles to:
// From Generated/SecsModule.cs
public static readonly EventEntry[] Events =
[
DemonRaidEvent.Registration,
PlagueSpreadsEvent.Registration,
BuildingCompleteMoraleEvent.Registration,
CelebrationBonusEvent.Registration,
FamineStartsEvent.Registration,
RaidResolvedVictoryEvent.Registration,
RaidResolvedDefeatEvent.Registration,
];
From Generated/SecsModule.cs lines 137-145.
Why this shape: Symmetric with SystemRegistration. Each event class owns its registration; SecsModule only aggregates. Priority and Weight are optional (have defaults) because most events do not need them; they only appear in the initializer when non-default.
Mod scope note: EventEntry[] Events array position has no semantic meaning — the engine indexes events by EventId hash, not by array slot. Position within the merged array is fixed by the rule in 00-overview.md § "Compiler-output ordering convention": base events occupy positions 0..N_base - 1, mod-added events append in load order, and inject / replace writes apply in place without changing the slot. Re-merge on load-order change does not shuffle base positions and never affects engine dispatch — the array is purely a registration manifest.
Notes:
- There is no
SystemSource-analogEventSourcefield onEventEntry. Host-registered events (if any) are not distinguished from SECS-generated ones in the current engine. Whether that matters for mod-operation dispatch/provenance is unresolved — until anEventSourcefield exists, the registry cannot tell whether a given event is mod-targetable or host-protected. FUTURE_WORK § 5.18 tracks this.
End-to-end: PlagueSpreadsEvent
What: The canonical player-choice event. Pulse-triggered, monthly, 5% chance, condition on population, two options each with its own effect body.
SECS: From examples/valenar/Content/events/settlement/crisis/plague_spreads.secs:
event PlagueSpreads
{
trigger pulse;
frequency Monthly;
chance 5;
query bool Condition()
{
return @Settlement.resolve(Population) > 20;
}
method void BuildOptions(EventOptions options)
{
options.Add("Quarantine", Quarantine);
options.Add("Ignore", Ignore);
}
method void Quarantine()
{
@Settlement.add_modifier QuarantinePenalty duration 10;
@Settlement.increment(Morale, -10);
}
method void Ignore()
{
@Settlement.add_modifier PlagueSickness duration 15;
}
}
Compiles to: The full PlagueSpreadsEvent.cs shown above. Walk-through of the lowering:
trigger pulse+frequency Monthly+chance 5→EventTriggerType.Pulse,Frequency => 30,Chance => 5.query bool Condition() { return @Settlement.resolve(Population) > 20; }-> theCondition(scope, ctx)override callingResolveIntFast.BuildOptions(EventOptions options)registers explicit names and handler methods. The dispatcher queues a choice when the built options list is non-empty.- Body of
Executeis omitted because this event's committed behavior is entirely in option handlers. - Inside each option:
@Settlement.add_modifier QuarantinePenalty duration 10→ctx.Commands.AddModifier(scope.Owner, scope.Owner, H.QuarantinePenalty, 0, 10). The0trigger id means "no trigger" (unconditional). The duration argument is10.@Settlement.increment(Morale, -10)→ctx.Commands.IncrementScopeField(scope.Owner, H.Settlement, H.Morale, -10).
- Every option ends with the queue-process-clear trio for the same reason systems do (intra-body visibility).
Why this shape: The player-choice path is one branch of the event dispatcher; by lowering to SecsEvent.BuildOptions the compiler keeps all events on one base class, with no separate IPlayerChoiceEvent hierarchy. The engine can treat every event identically at the call site and branch on whether the builder produced options.
Notes: None for the current event lowering. A future log command is tracked in FUTURE_WORK.md; current .secs fixtures avoid @log(...) until the engine exposes a command surface for it.
On-actions
Current contract: The only on-action source surface that is committed for Roslyn bring-up is declaration metadata:
scope,provides scope:X, andmode, plus event subscription throughtrigger <on_action_name>. On-actions do not own source-authored effect bodies. If content needs a guaranteed effect at that extension point, declare a normal subscriber event with an earlypriorityand put the effect in itsExecute()method.
on_action Name — declaration
What: A named extension point. Execute-style code in systems or events fires an on-action by name; subscribed events with matching trigger <name> receive the fire. The committed declaration surface says: (a) the scope type of the target, (b) which saved scopes are guaranteed to be available to subscribers, and (c) how subscribers are selected.
Valenar authors on-actions in domain files under Content/on_actions/. Generated output mirrors those domains under Generated/OnActions/{Settlement,Combat,World}/, then emits a small root aggregate Generated/OnActions/OnActionDeclarations.cs so SecsModule still registers one deterministic OnActionDeclaration[] All array.
SECS:
on_action on_building_complete
{
scope Settlement;
provides scope:Location, scope:Building;
mode all;
}
Compiles to:
new OnActionDeclaration
{
OnActionId = H.OnAction_BuildingComplete,
Name = "on_building_complete",
ScopeId = H.Settlement,
SelectionMode = EventSelectionMode.All,
ProvidedScopes = new Dictionary<ulong, ulong>
{
[H.Location] = H.Location,
[H.Building] = H.Building,
},
},
Reduced from examples/valenar/Generated/OnActions/Settlement/SettlementOnActionDeclarations.cs to the committed declaration fields. The old prototype Effect delegate for on_building_complete has been removed from generated stand-ins; its source-level replacement is the normal subscriber event BuildingCompleteMorale, which runs before ordinary subscribers by priority.
The .secs spelling provides scope:Location and the generated hash pair [H.Location] = H.Location are the same contract at different levels: the first hash is the saved-scope wire id, the second is the expected scope type. ConstructionSystem saves H.Location and H.Building before firing this on-action.
Why this shape: On-actions are declarations, not classes. Unlike systems and events, they have no instance state and no interface to implement — they are a record the dispatcher looks up by id. Domain generated files keep source ownership local, while the root aggregate means SecsModule still registers them with a single loop (foreach (var d in OnActionDeclarations.All) registry.RegisterOnAction(d)). Name is preserved verbatim from .secs (keeping the underscore) because it shows up in debug output where the .secs-style identifier is more recognizable than the hash constant name.
Notes: Name stays the .secs identifier (on_building_complete) so debug output and generated provenance continue to match source spelling.
Selection modes (all / first_valid / weighted)
What: mode all fires every subscribed event whose condition passes. mode first_valid fires only the first (by priority-sorted registration order) whose condition passes, skipping the rest. mode weighted treats all condition-passing events as a weighted pool and picks one.
SECS:
on_action on_building_complete { scope Settlement; mode all; /* ... */ }
on_action on_famine { scope Settlement; mode first_valid; }
on_action on_raid_resolved { scope Settlement; mode weighted; /* ... */ }
Compiles to:
SelectionMode = EventSelectionMode.All,
SelectionMode = EventSelectionMode.FirstValid,
SelectionMode = EventSelectionMode.WeightedRandom,
From the domain generated on-action declaration files.
Why this shape: The three modes are enum values on EventSelectionMode. All is the default — omitting mode in .secs produces SelectionMode = EventSelectionMode.All implicitly (see OnActionDeclaration init value). FirstValid and WeightedRandom are explicit choices. The dispatcher's branch on selection mode is in EventDispatcher.FireOnAction, not in the events themselves; events don't know or care whether their on-action is All or WeightedRandom. They just declare weight = N and let the on-action's mode decide whether that weight matters.
weighted (.secs) vs WeightedRandom (C# enum) is an intentional naming difference — the .secs author writes one word, the engine enum is explicit that selection is random-weighted (not lexicographic by weight).
Mod scope note: EventSelectionMode is a closed three-value enum; mods cannot extend it. Subscriber selection under mode first_valid is governed by event priority (default 0): the lowest-priority condition-passing event fires first, the rest are skipped. When base and mod events subscribe to the same first_valid on-action, the priority field decides the winner regardless of source set — a mod event with priority -100 will pre-empt every base subscriber whose priority is higher. Mod authors are expected to keep priority values in the 0–99 range for ordinary content; negative values are reserved for platform-critical or compatibility-shim events that must run before ordinary subscribers. The compiler does not enforce this convention today, but a future warning may fire when a mod-declared event uses a reserved priority range. mode weighted does not have the same pre-emption risk because every condition-passing subscriber is in the pool — a high-weight mod event biases the RNG without locking out base events entirely.
Notes: The live convention keeps ordinary mod event priorities in the 0-99 range. Any future warning for out-of-range values is backlog only and does not change dispatch semantics.
provides scope:X — the subscription contract
What: provides scope:X, scope:Y declares that when this on-action fires, the engine will have saved scopes named X and Y that subscribing events can read via scope:X. The compiler uses the provides list to validate that subscribing events only reference scopes the on-action actually provides.
SECS:
on_action on_building_complete
{
scope Settlement;
provides scope:Location, scope:Building;
mode all;
}
// Consumer:
event CelebrationBonus
{
trigger on_building_complete;
method void Execute()
{
var location = scope:Location; // validated against provides
if (location != null)
location.add_modifier BountifulHarvest owned_by @Settlement duration 5;
}
}
Compiles to:
// Declaration side:
ProvidedScopes = new Dictionary<ulong, ulong>
{
[H.Location] = H.Location,
[H.Building] = H.Building,
},
// Consumer side (CelebrationBonusEvent.Execute):
var location = ctx.GetSavedScope(H.Location);
if (!location.IsNull)
{
ctx.Commands.AddModifier(scope.Owner, location, H.BountifulHarvest, 0, 5);
ctx.FlushCommands();
}
From SettlementOnActionDeclarations.cs and CelebrationBonusEvent.cs.
Why this shape: ProvidedScopes is a Dictionary<ulong, ulong> mapping scope-name-hash → scope-type-hash. The compiler uses the mapping for two checks: (1) event uses of scope:X must match a name in the on-action's provides list, and (2) each generated fire on_action site must save every provided name before firing. Runtime dispatch provides the safety net: EventDispatcher.FireOnAction pushes the dispatch frame, then checks that every declared provided-scope key is present before any subscriber condition or body runs. The runtime check is by key presence, not by EntityHandle.IsNull, so integer-like payloads encoded as EntityHandle.Value may legally be 0. Runtime does not validate the scope-type value because the generic host bridge has no universal "entity has scope type" query; type correctness remains a compiler/static-analysis responsibility.
scope:name == int_literal (as in scope:WasVictory == 1) is a legal compile-time form because saved scopes can be EntityHandles carrying integer-like payloads. The compiler emits ctx.GetSavedScope(H.WasVictory).Value == 1. See RaidResolvedDefeatEvent.Condition.
Mod scope note: The provides clause on an on-action is a closed slot under the merge pass. A mod may not extend a base on-action's provides list with any mod operation. Reason: every firing site of an on-action (a fire on_action foo target X call inside a system or event body) is responsible for saving exactly the scopes the on-action declares. If a mod could add a new provided scope, every base firing site would silently fail to save it, and mod-authored subscribers reading scope:newThing would receive EntityHandle.Null with no diagnostic. The compiler must reject extensions of provides with a binder diagnostic (proposed SECS0403) at the operation site. Mods that need a new saved scope must declare a new on-action with its own firing contract; source syntax for automatic chaining is not committed.
Notes: The live contract requires scope:X reads and fire on_action sites to match the declared provides set, and it rejects mod extension of that set. Remaining compiler and diagnostic wiring is tracked in docs/design/FUTURE_WORK.md.
Dispatch ordering and saved-scope lifetime
What: When an on-action fires, the engine executes in this strict order:
- Subscribed events — events whose
triggermatches, filtered byCondition(), picked according toSelectionMode, fired in ascending priority order (lower numbers first). - Saved-scope frame pop/restore — the dispatch frame is popped even if a subscriber throws, restoring the caller's frame.
Reserved runtime chain/fallback metadata is not part of the current source contract and is tracked as future work rather than live syntax.
SECS:
on_action on_building_complete
{
scope Settlement;
provides scope:Location, scope:Building;
mode all;
}
event CelebrationBonus
{
trigger on_building_complete;
method void Execute()
{
@Settlement.increment(Morale, 5); // subscriber event body
}
}
If the extension point needs a guaranteed base effect, source it as an ordinary subscriber event:
event BuildingCompleteMorale
{
trigger on_building_complete;
priority -100;
method void Execute()
{
@Settlement.increment(Morale, 2);
}
}
Compiles to: subscriber events are selected by EventDispatcher.FireOnAction; each chosen event then runs its normal Execute method:
public override void Execute(EventContext context)
{
var scope = context.Scope;
var ctx = context.Tick;
ctx.Commands.IncrementScopeField(scope.Owner, H.Settlement, H.Morale, 5);
ctx.FlushCommands();
// ...
}
Why this shape: The committed compiler problem is not an on-action body syntax; it is the dispatch contract. The compiler must be able to validate that events subscribing to an on-action only read saved scopes declared by that on-action, and that firing sites provide those scopes before dispatch. Guaranteed effects are just early subscriber events, which keeps all behavior in the same event slot and priority model. Fallbacks and chains remain deferred metadata slots; the current runtime does not execute or validate them as live follow-on dispatch.
Saved-scope frame contract: TickContext maintains saved scopes as dispatch frames, not as accidental global mutable state.
SaveScope(name, value)writes into the current active dispatch frame. If no dispatch frame is active, it writes into a pending pre-fire table.FireOnActionpushes a child frame initialized from the caller's currently visible saved scopes, then clears top-level pending pre-fire scopes so host/system pre-saves are consumed by that fire instead of leaking globally.- Subscriber events in the same on-action dispatch share that child frame. A
save_scope_asin an earlier subscriber is visible to later subscribers in priority order. - A nested
FireOnActiongets a snapshot copy of the caller frame. Writes made by the nested dispatch do not leak back to the caller frame; writes made by the caller before the nested fire remain visible after it returns. - Reserved fallback / chained metadata does not create follow-on dispatch today. Only explicit nested
FireOnActioncalls start a new child frame. - Player-choice events snapshot the current saved-scope frame when they queue a
PendingChoice. Resolving the choice later pushes that saved snapshot as a temporary frame for the selected option and then restores whatever ambient frame or pending scopes existed before resolution. ClearSavedScopes()clears only the currently active frame, or the pending pre-fire table when no frame is active.
Notes: The live contract guarantees frame restoration on unwind. Any finer-grained exception policy for subscriber continuation is tracked in docs/design/FUTURE_WORK.md.
End-to-end: on-action declarations
What: The canonical on-action surface — Valenar on-actions are declared in domain .secs files and lowered to domain generated declaration files, with a root aggregate that preserves one registration array for SecsModule.
SECS: From examples/valenar/Content/on_actions/settlement/building_complete.secs, examples/valenar/Content/events/settlement/construction/building_complete_morale.secs, examples/valenar/Content/on_actions/settlement/famine.secs, examples/valenar/Content/on_actions/combat/raid_resolved.secs, and examples/valenar/Content/on_actions/world/day_start.secs:
on_action on_building_complete
{
scope Settlement;
provides scope:Location, scope:Building;
mode all;
}
event BuildingCompleteMorale
{
trigger on_building_complete;
priority -100;
method void Execute()
{
@Settlement.increment(Morale, 2);
}
}
on_action on_famine
{
scope Settlement;
mode first_valid;
}
on_action on_raid_resolved
{
scope Settlement;
provides scope:WasVictory;
mode weighted;
}
on_action on_day_start
{
scope Settlement;
}
Compiles to:
public static class OnActionDeclarations
{
public static readonly OnActionDeclaration[] All =
[
.. SettlementOnActionDeclarations.All,
.. CombatOnActionDeclarations.All,
.. WorldOnActionDeclarations.All,
];
}
public static class SettlementOnActionDeclarations
{
public static readonly OnActionDeclaration[] All =
[
new OnActionDeclaration
{
OnActionId = H.OnAction_BuildingComplete,
Name = "on_building_complete",
ScopeId = H.Settlement,
SelectionMode = EventSelectionMode.All,
ProvidedScopes = new Dictionary<ulong, ulong>
{
[H.Location] = H.Location,
[H.Building] = H.Building,
},
},
new OnActionDeclaration
{
OnActionId = H.OnAction_Famine,
Name = "on_famine",
ScopeId = H.Settlement,
SelectionMode = EventSelectionMode.FirstValid,
},
];
}
public static class CombatOnActionDeclarations
{
public static readonly OnActionDeclaration[] All =
[
new OnActionDeclaration
{
OnActionId = H.OnAction_RaidResolved,
Name = "on_raid_resolved",
ScopeId = H.Settlement,
SelectionMode = EventSelectionMode.WeightedRandom,
ProvidedScopes = new Dictionary<ulong, ulong>
{
[H.WasVictory] = H.Settlement,
},
},
];
}
public static class WorldOnActionDeclarations
{
public static readonly OnActionDeclaration[] All =
[
new OnActionDeclaration
{
OnActionId = H.OnAction_DayStart,
Name = "on_day_start",
ScopeId = H.Settlement,
},
];
}
Reduced from examples/valenar/Generated/OnActions/Settlement/SettlementOnActionDeclarations.cs, examples/valenar/Generated/OnActions/Combat/CombatOnActionDeclarations.cs, examples/valenar/Generated/OnActions/World/WorldOnActionDeclarations.cs, and the root aggregate at examples/valenar/Generated/OnActions/OnActionDeclarations.cs. The old prototype Effect delegate for on_building_complete is represented in source and generated C# as the BuildingCompleteMorale subscriber event above.
Why this shape: On-actions are a flat registry, not a class hierarchy. Domain arrays keep the generated file layout aligned with source ownership; the root aggregate keeps the runtime registration surface unchanged. The provides list is emitted as a Dictionary<ulong, ulong>; each entry is a (scopeNameHash -> scopeTypeHash) pair. When scope:WasVictory is used to encode an int flag (0 or 1), the scope-type field is set to any valid scope hash (H.Settlement in Valenar's code) because the dispatcher does not interpret the type for non-handle payloads.
Missing fields default sensibly: on_day_start has no provides, no chain, no fallback — it is a pure extension point wired by Valenar's host before daily systems run. ChainedOnActionIds = null and FallbackOnActionId = 0 are reserved inert metadata initializers, so the declaration omits them.
Current exclusion: Valenar's committed on-action source files do not use chain-fire syntax. Chained and fallback on-actions remain reserved runtime metadata with no committed .secs spelling; the live dispatcher ignores them even if host code sets the fields. Future source syntax and mod semantics are tracked in docs/design/FUTURE_WORK.md.
Activities
Current contract: Activities are class-based SECS definitions with readonly
fielddata plus explicitquery/methodlifecycle members. The runtime surface isSecsActivityplus per-runActivityRunstate advanced byActivityExecutor. Older block/delegate prototypes are historical only.
activity Name — class shell
What: A player/AI-initiated interaction. An activity owns actor/target binding and lifecycle methods; active executions are separate ActivityRun instances. Supports both instant and timed behavior.
SECS:
activity RecruitGarrison
{
actor settlement;
field int Cooldown = 7;
field string WireId = "recruit-garrison";
field string Description = "Recruit defenders from the settlement population.";
field string Icon = "shield";
field int DurationTicks = 0;
query bool IsVisible()
{
return true;
}
query bool CanStart()
{
return actor.resolve(Population) > 5
&& actor.Gold >= 25;
}
method void OnStart(ActivityRun run)
{
actor.increment(Gold, -25);
actor.increment(Garrison, 5);
actor.increment(Population, -2);
run.Complete();
}
}
Compiles to:
public sealed class RecruitGarrisonActivity : SecsActivity
{
public static readonly RecruitGarrisonActivity Instance = new();
private RecruitGarrisonActivity() { }
public override ulong ActivityId => H.Activity_RecruitGarrison;
public override string Name => "Recruit Garrison";
public override ulong ActorScopeId => H.Settlement;
public override ulong TargetScopeId => 0;
public override int Cooldown => 7;
public override int DurationTicks => 0;
public override bool IsVisible(ActivityContext ctx) => true;
public override bool CanStart(ActivityContext ctx)
{
return ctx.Tick.ChannelResolver.ResolveIntFast(ctx.Actor, H.Population) > 5
&& ctx.Tick.Host.ReadInt(ctx.Actor, H.Settlement, H.Gold) >= 25;
}
public override void OnStart(ActivityRunContext ctx)
{
ctx.Tick.Commands.IncrementScopeField(ctx.Actor, H.Settlement, H.Gold, -25);
ctx.FlushCommands();
ctx.Tick.Commands.IncrementScopeField(ctx.Actor, H.Settlement, H.Garrison, 5);
ctx.FlushCommands();
ctx.Tick.Commands.IncrementScopeField(ctx.Actor, H.Settlement, H.Population, -2);
ctx.FlushCommands();
ctx.Complete();
}
}
Why this shape: Activities are reusable definitions, not active executions and not bags of delegate slots. The definition contains readonly activity fields plus method bodies. Every execution creates an ActivityRun record that carries Actor, optional Target, start tick, elapsed ticks, delta ticks, duration, and status; the lifecycle call receives an ActivityRunContext that pairs the run with the current TickContext and command buffer. Generic SECS owns the actor/target/lifecycle protocol; the game owns any host-exposed scope methods called from the bodies.
The actor settlement; declaration binds the plain identifier actor to the activity actor and records the expected actor scope (H.Settlement). target location; does the same for target on targeted activities. These are ordinary source locals inside activity methods, not sigil syntax and not extra C# parameters written by the author.
Activity field declarations are static definition data, the same conceptual role template fields play for templates. They are the right home for catalog ids, labels, grouping, sort order, icon keys, constant duration, authored costs, and authored reward previews. Runtime-dependent answers stay in query members; command-producing behavior stays in method members.
For UI/API integration, expose two projections rather than sending everything
with every snapshot: a catalog projection from SecsRegistry.AllActivityDeclarations()
for static activity fields, and a narrow availability query for a specific
actor/target context. The availability query returns activity ids plus start state;
the client joins those ids against the catalog it already loaded. Starting an
activity resolves the submitted id back through the registered SecsActivity; it
must not call a game-side parallel catalog.
ActivityRequestOrigin is runtime/save provenance, not a UI surface. The
server may use it for attribution, replay, analytics, or save migration
decisions, but the client-facing activity catalog, availability rows, and
queue projection do not need to expose origin unless a product-facing feature
explicitly consumes it.
Valenar exposes these through a generic app operation boundary rather than
panel-specific hub methods. GameHub has two public calls: Query(request) for
read models and Command(request) for mutations. Typed client helpers may wrap
those ids, but they must not add another hub method per UI panel.
Query("character.sheet")returns the current MC sheet projection.Query("map.snapshot")returns the heavy map read model. It is fetched on startup and when the live map version changes, not pushed every tick.Query("activity.catalog")returns the static activity catalog.Query("activity.available", { locationId })returns actor/location availability rows (id,canStart,blockedReason) for the current MC context.Query("activity.queue")returns the server-owned queue projection.Query("building.options", { locationId })andQuery("tooltip.resolve", { tipId })are ordinary read-model queries, not panel-specific endpoints.Command("game.set_speed", { speed }),Command("character.move", { locationId }),Command("base.found", { locationId }), and thebuilding.*/choice.resolvecommands are the general mutation surface for app controls.Command("queue.enqueue_activity", { activityId, locationId, stop })appends a scheduled activity intention; if the activity target is not the queue tail, the server inserts a travel item using the same location graph that validates movement.Command("queue.enqueue_travel", { toLocationId }),Command("queue.cancel_*"), andCommand("queue.move_item")mutate the same queue.
The client may cache catalog definitions and render queue rows, but it must not
advance activity progress locally or start SecsActivity runs itself. GameHub
pushes LiveChanged(GameLiveSnapshot), a narrow live model containing time,
speed, current character/settlement state, buildings, log, pending choices,
active runs, the server-owned activity queue, and read-model version numbers.
Heavy read models such as map.snapshot and character.sheet are queried by
id and refreshed only when their live version changes. The server advances
travel, starts SECS activity runs, applies rewards/costs through generated activity
methods, and removes or repeats queue items when runs complete. This keeps UI
panels from owning gameplay state while still letting them compose the same
query/command surface in different views.
Notes: None for the committed field/query/method split. Games may add their own structured field types for richer cost and reward previews.
Targeted activities
What: A targeted activity declares both actor <scope>; and target <scope>;. IsVisible controls actor-level visibility, IsTargetValid filters candidate targets, and CanStart is the final start guard for the chosen actor/target pair.
SECS: From examples/valenar/Content/activities/location/scout_nearby_lead.secs:
activity ScoutNearbyLead
{
actor character;
target location;
field int DurationTicks = 4;
query bool IsVisible()
{
return actor.CurrentLocationId != 0;
}
query bool IsTargetValid()
{
return target.Explored == 0;
}
query bool CanStart()
{
return actor.CurrentLocationId != 0
&& actor.Stamina > 0;
}
method void OnStart(ActivityRun run)
{
actor.DrainStamina(1);
}
method void OnUpdate(ActivityRun run)
{
actor.AdvanceLead(target, 25);
if (actor.CurrentLocationId == 0)
{
run.Fail();
return;
}
if (target.Explored != 0)
{
run.Complete();
}
}
method void OnComplete(ActivityRun run)
{
target.Reveal();
actor.GainXp(5);
}
method void OnCancel(ActivityRun run)
{
actor.AdvanceLead(target, -10);
}
method void OnFail(ActivityRun run)
{
actor.DrainStamina(1);
}
}
Compiles to:
public sealed class ScoutNearbyLeadActivity : SecsActivity
{
public static readonly ScoutNearbyLeadActivity Instance = new();
private ScoutNearbyLeadActivity() { }
public override ulong ActivityId => H.Activity_ScoutNearbyLead;
public override string Name => "Scout Nearby Lead";
public override ulong ActorScopeId => H.Character;
public override ulong TargetScopeId => H.Location;
public override int DurationTicks => 4;
public override bool IsVisible(ActivityContext ctx)
{
return ctx.Tick.Host.ReadInt(ctx.Actor, H.Character, H.CurrentLocationId) != 0;
}
public override bool IsTargetValid(ActivityContext ctx, EntityHandle target)
{
return ctx.Tick.Host.ReadInt(target, H.Location, H.Explored) == 0;
}
public override bool CanStart(ActivityContext ctx)
{
return ctx.Tick.Host.ReadInt(ctx.Actor, H.Character, H.CurrentLocationId) != 0
&& ctx.Tick.Host.ReadInt(ctx.Actor, H.Character, H.Stamina) > 0;
}
public override void OnStart(ActivityRunContext ctx)
{
CharacterScopeCommands.DrainStamina(ctx.Tick, ctx.Actor, 1);
ctx.FlushCommands();
}
public override void OnUpdate(ActivityRunContext ctx)
{
CharacterScopeCommands.AdvanceLead(ctx.Tick, ctx.Actor, ctx.Target, 25);
ctx.FlushCommands();
if (ctx.Tick.Host.ReadInt(ctx.Actor, H.Character, H.CurrentLocationId) == 0)
{
ctx.Fail();
return;
}
if (ctx.Tick.Host.ReadInt(ctx.Target, H.Location, H.Explored) != 0)
{
ctx.Complete();
}
}
public override void OnComplete(ActivityRunContext ctx)
{
LocationScopeCommands.Reveal(ctx.Tick, ctx.Target);
ctx.FlushCommands();
CharacterScopeCommands.GainXp(ctx.Tick, ctx.Actor, 5);
ctx.FlushCommands();
}
public override void OnCancel(ActivityRunContext ctx)
{
CharacterScopeCommands.AdvanceLead(ctx.Tick, ctx.Actor, ctx.Target, -10);
ctx.FlushCommands();
}
public override void OnFail(ActivityRunContext ctx)
{
CharacterScopeCommands.DrainStamina(ctx.Tick, ctx.Actor, 1);
ctx.FlushCommands();
}
}
DrainStamina, AdvanceLead, Reveal, and GainXp are Valenar host-exposed scope methods declared on scope Character / scope Location. They are not generic SECS syntax. Another game can expose different methods on its host bridge without changing the activity lifecycle model. Because they return void, the compiler only allows them in command-context bodies and lowers known call sites through generated typed command facades over ISecsHostCommands; read-only activity methods such as CanStart keep using ISecsHostReads.
Why this shape: Visibility, target validity, and start validity are distinct questions. IsVisible decides whether the activity is offered for an actor at all. IsTargetValid filters candidate targets for targeted activities. CanStart runs after a concrete target is chosen and is the last guard before a run is created. Keeping them as plain methods preserves normal C# control flow and avoids inventing a mini-DSL for predicates.
Notes: None for the activity surface. Known generated activity call sites use typed scope command/query facades, and the read-only versus command-producing bridge split is implemented.
Activity args (typed parameters)
What: Many activities share the same workflow shape but vary by content selection — BuildStructure(BuildingTemplateId), UseItem(ItemId), a future CastSpell(SpellTemplateId). Rather than duplicate the activity per content row, the actor passes a typed args record in the ActivityRequest; the activity's lifecycle members read it via context.Args.Decode<T>().
SECS source (future):
activity BuildStructure
{
actor settlement;
target location;
args BuildStructureArgs;
query bool CanStart() { return runtime.GetBuildOptionsForLocation(target).Contains(args.BuildingTemplateId); }
method void OnStart(ActivityRun run) { runtime.EnqueueBuild(target, args.BuildingTemplateId); run.Complete(); }
}
record BuildStructureArgs(TemplateId BuildingTemplateId);
Compiles to: Each typed-args record implements IActivityArgs<TSelf> with a stable SchemaId and round-trip Encode / DecodeFrom:
public readonly record struct BuildStructureArgs(TemplateId BuildingTemplateId)
: IActivityArgs<BuildStructureArgs>
{
public static readonly ulong Schema = FnvHash.Compute("BuildStructureArgs(TemplateId)");
public ulong SchemaId => Schema;
public ActivityArgsBlob Encode()
{
var bytes = new byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(bytes, BuildingTemplateId.Value);
return new ActivityArgsBlob(Schema, bytes);
}
public BuildStructureArgs DecodeFrom(ActivityArgsBlob blob)
=> new(new TemplateId(BinaryPrimitives.ReadUInt64LittleEndian(blob.Payload.Span)));
}
ActivityRequest carries an ActivityArgsBlob (schema-id + byte payload). ActivityRun.Args and ActivityRunState.Args round-trip the blob bytes verbatim, so a saved run can be restored without losing the args. The same run/state path also preserves ActivityRequestOrigin, so a restored run keeps truthful player/policy/system/event/mod attribution. Schema-id is FNV-1a-64 of the args record's full type signature; on a schema-id mismatch Decode<T> throws InvalidOperationException, which the host should treat as a save-migration miss (cancel the run, log, optionally consult a future args-migration table).
Wire surface: ActivityRunSnapshot.Args is ActivityArgsBlobSnapshot(string SchemaIdHex, string PayloadBase64) — schema id is serialized as 0x... hex string (not JSON number) because FNV hashes routinely exceed 2^53. EnqueueActivityCommandArgs carries the same pair so a SignalR client can supply args when enqueuing a parameterised activity.
Generic rule: a content family that shares one mechanic does not need one activity per content row. Row-specific knobs live in content definition data, the shared behavior lives in one generic activity, and the chosen definition row travels in the ActivityArgsBlob as a typed definition reference.
Content-family parameterization
Every family that "shares an activity skeleton but varies by data row" follows the same pattern:
| Layer | Role |
|---|---|
| Content definition data | Stores the per-row knobs and metadata for the family. |
| Generic mechanic activity | Implements the shared behavior for that family. |
| Typed args | Carries the chosen definition reference into the run. |
| Candidate materialization | Turns available definition references into ActivityRequests carrying that args blob. |
Adding a new definition row in an existing family is therefore a content-only change. It does NOT require a new SecsActivity declaration, a new Activity_<Name> hash, or a new args type. The vocabulary guard in scripts/check-behavior-vocabulary.sh enforces this by treating per-row Activity_<ContentName> identifiers as the content-explosion smell.
Valenar example: spells currently use this pattern with SpellDefinition data, the generic CastSpell activity, and CastSpellArgs.SpellDefinitionId carrying the chosen spell definition id. That is an example from one game, not generic engine vocabulary.
The current runtime/lowering bridge that turns stored definition references into ActivityRequests is documented in 10-host-secs-execution-boundary.md § 17.3-17.4.
Lifecycle methods
What: Activity bodies are explicit query or method lifecycle members. The generic lifecycle vocabulary is fixed: IsVisible, IsTargetValid, CanStart, dynamic GetDurationTicks, OnStart, OnUpdate, OnComplete, OnStop, OnCancel, and OnFail. Static definition data such as DurationTicks, Cooldown, presentation metadata, grouping, costs, and reward previews belongs in activity field declarations. AI weighting is a Wave 4 policy concern (policy { need ... }), not an activity concern; activities expose their predicted effects via Preview and the policy executor scores them.
SECS:
activity TravelToLocation
{
actor character;
target location;
field int DurationTicks = 6;
query bool IsVisible() { return actor.CurrentLocationId != 0; }
query bool IsTargetValid() { return target.Explored == 0; }
query bool CanStart() { return actor.CurrentLocationId != 0 && actor.Stamina > 0; }
method void OnStart(ActivityRun run) { actor.DrainStamina(1); }
method void OnUpdate(ActivityRun run)
{
if (target.Explored != 0)
{
run.Complete();
}
else if (actor.CurrentLocationId == 0)
{
run.Stop();
}
}
method void OnComplete(ActivityRun run) { actor.GainXp(1); }
method void OnStop(ActivityRun run) { }
method void OnCancel(ActivityRun run) { }
method void OnFail(ActivityRun run) { }
}
Compiles to:
// Each method lowers to one generated method on the activity definition.
// Lifecycle transitions are requested through ActivityRunContext.
activity.OnUpdate(context);
// Examples of run state transitions:
context.Complete(); // successful completion; runtime invokes OnComplete once
context.Stop(); // neutral/interrupted stop; runtime invokes OnStop once
context.Cancel(); // actor/player cancellation; runtime invokes OnCancel once
context.Fail(); // failed execution; runtime invokes OnFail once
Why this shape: Constant duration is an activity field and lowers to SecsActivity.DurationTicks. Dynamic duration is a query int GetDurationTicks() when it truly depends on actor/target state or a host-exposed read. The runtime evaluates duration at start time and stores the resulting value on the ActivityRun; the active run does not keep re-querying duration unless a later design explicitly adds dynamic duration.
OnStart runs once after CanStart passes and a run is created. OnUpdate runs for active timed activities. A run reaches a terminal state when the lifecycle method calls run.Complete(), run.Stop(), run.Cancel(), or run.Fail(), or when the runtime's duration policy completes it. The corresponding terminal hook (OnComplete, OnStop, OnCancel, OnFail) runs once.
Notes:
- None. Duration expiry is successful completion unless the activity has already stopped, failed, or canceled itself.
query EffectPlan Preview(ActivityContext context) — predicted effects
What: A read-only query lifecycle member that returns an EffectPlan describing what the activity would do if started for the supplied actor/target/args. Used by the UI tooltip layer to render expected costs and rewards, and by the future policy/AI layer to score candidate activities.
Source:
activity ScoutNearbyLead
{
actor character;
target location;
field int DurationTicks = 6;
query EffectPlan Preview()
{
return new EffectPlan(
[
PredictedEffect.Field(actor, H.Stamina, PredictionOp.Add, -1, durationTicks: DurationTicks),
PredictedEffect.Field(target, H.LeadProgress, PredictionOp.Add, 25 * DurationTicks, durationTicks: DurationTicks),
PredictedEffect.Field(actor, H.Xp, PredictionOp.Add, +5, delayTicks: DurationTicks),
],
PredictionConfidence.Exact);
}
}
Compiles to: A public override EffectPlan Preview(ActivityContext context) method on the generated ScoutNearbyLeadActivity class. Source-side actor/target lower to context.Actor / context.Target. The return shape is the runtime EffectPlan record with an IReadOnlyList<PredictedEffect> and an aggregate PredictionConfidence.
Why this shape: Preview must produce the same effect rows that Apply mode will mutate. The runtime cannot derive Preview from OnStart/OnUpdate/OnComplete bodies because those bodies contain conditionals, host calls, and randomness. Authoring Preview as an explicit query keeps the semantic contract authored once, in one place, and lets activities that compute effects from structured definition data (e.g. CharacterSiteActivityDefinition.SkillXp and Resources) share a default Preview implementation on a base class.
Default: Activities that don't override Preview return EffectPlan.Empty. UI surfaces render no preview rows in that case.
Static analysis (Wave 5): the registry validation pass emits
SECS0415 (PolicyVisibleActivityMissingPreview) at Warning severity
when a policy selector can route to an activity that inherits the
EffectPlan.Empty default. Under the Wave 5 scoring contract such an
activity ranks unrelated against every need (relevance 0), so the
diagnostic surfaces the missing override at registration time. The
check is purely reflective on the activity type — it never invokes
Preview() at registration time. SelectorSource.FromCollection is
intentionally not checked, because the candidate builder yields
activities at runtime from the actor's slot, which static analysis
cannot enumerate.
Runtime drift telemetry (Wave 5): hosts can opt into
PreviewDriftRecorder by assigning it to
ActivityExecutor.PreviewRecorder. The recorder snapshots every
channel-targeting effect's starting value at run-start and re-reads the
channels at run-complete, emitting one PreviewDriftRecord per
successful run with predicted-vs-actual deltas. Cancelled, stopped,
and failed runs are not recorded — drift is a success-path metric.
The recorder is opt-in (null = telemetry off, no overhead) per the
no-silent-fallback tenet.
Confidence levels:
Exact— fully deterministic given the supplied actor/target/args.Probable— depends on state the preview can read, but the row's probability is < 1.0 (e.g. aRevealthat fires only when accumulated lead progress reaches a threshold).Estimated— a dynamic-formula effect whose amount cannot be evaluated without a live owner context;IsDynamic = trueandFormulaIdcarries the formula reference.
The plan-level Confidence is the worst row's confidence (Exact < Probable < Estimated), so a single dynamic row downgrades the aggregate.
predicts { ... } metadata is reserved for a future wave (Wave 3+) where many activities will share lifecycle bodies and benefit from automatic prediction propagation. In Wave 2, every activity that previews effects authors Preview explicitly.
Notes: The shape returned by Preview is the runtime contract for tooltip rendering. Metadata-based predicts { } syntax remains deferred in docs/design/FUTURE_WORK.md.
Instant and timed behavior
What: An instant activity completes during OnStart or at the end of the start step when duration is zero. A timed activity returns a positive duration and stays active across ticks until completion, stop, cancel, or failure.
SECS:
activity StudyTrail
{
actor character;
target location;
field int DurationTicks = 6;
query bool CanStart() { return actor.CurrentLocationId != 0; }
method void OnUpdate(ActivityRun run)
{
actor.AdvanceLead(target, 10);
if (actor.CurrentLocationId == 0)
{
run.Cancel();
}
}
method void OnComplete(ActivityRun run)
{
target.Reveal();
actor.GainXp(3);
}
}
Compiles to: A reusable definition plus one active ActivityRun per execution:
var run = activityExecutor.Start(H.Activity_StudyTrail, queryCtx);
// Later ticks:
activity.OnUpdate(context);
if (run.ElapsedTicks >= run.DurationTicks && !run.IsTerminal)
{
context.Complete();
}
Why this shape: The definition is reusable and immutable; the run is the mutable execution record. This is the same separation used by many game ability/job systems: one authored activity definition can have many active executions by different actors, each with different targets, start ticks, progress, and terminal state.
Notes: None.
Generic SECS vs Valenar host methods
What: Generic SECS defines the activity declaration shape, actor/target binding, run lifecycle, and terminal transitions. Valenar defines game-specific host-exposed scope methods such as GainXp, Reveal, AdvanceLead, and DrainStamina.
SECS:
method void OnUpdate(ActivityRun run)
{
actor.DrainStamina(1); // Valenar host-exposed scope method
actor.AdvanceLead(target, 5); // Valenar host-exposed scope method
if (target.Explored != 0)
{
run.Complete(); // generic SECS lifecycle transition
}
}
Compiles to:
CharacterScopeCommands.DrainStamina(ctx.Tick, ctx.Actor, 1);
ctx.FlushCommands();
CharacterScopeCommands.AdvanceLead(ctx.Tick, ctx.Actor, ctx.Target, 5);
ctx.FlushCommands();
if (ctx.Tick.Host.ReadInt(ctx.Target, H.Location, H.Explored) != 0)
{
ctx.Complete();
}
Why this shape: SECS is modified C#, not a tiny DSL. Normal C# control flow, method calls, local variables, and returns remain valid inside activity methods unless a design doc restricts them. The compiler only rewrites SECS-aware operations (resolve, scope fields, host-exposed game methods, run transitions) to engine/host calls.
Notes: None.
End-to-end: ScoutNearbyLead
What: The canonical timed targeted activity. It validates actor/target, creates a timed run, advances a Valenar lead each update, and completes/cancels/fails through ActivityRun.
SECS: From examples/valenar/Content/activities/location/scout_nearby_lead.secs:
activity ScoutNearbyLead
{
actor character;
target location;
query bool IsVisible() { return actor.CurrentLocationId != 0; }
query bool IsTargetValid() { return target.Explored == 0; }
query bool CanStart() { return actor.CurrentLocationId != 0; }
field int DurationTicks = 4;
method void OnStart(ActivityRun run) { actor.DrainStamina(1); }
method void OnUpdate(ActivityRun run)
{
actor.AdvanceLead(target, 25);
if (actor.CurrentLocationId == 0) { run.Fail(); return; }
if (target.Explored != 0) { run.Complete(); }
}
method void OnComplete(ActivityRun run)
{
target.Reveal();
actor.GainXp(5);
}
method void OnCancel(ActivityRun run)
{
actor.AdvanceLead(target, -10);
}
method void OnFail(ActivityRun run)
{
actor.DrainStamina(1);
}
}
Compiles to: examples/valenar/Generated/Activities/Location/ScoutNearbyLeadActivity.cs. Walk-through of the lowering:
activity ScoutNearbyLead→ScoutNearbyLeadActivity, hashH.Activity_ScoutNearbyLead.actor character→ActorScopeId = H.Character; sourceactorlowers to the run/query actor entity.target location→TargetScopeId = H.Location; sourcetargetlowers to the candidate target in visibility/validation and to the run target during execution.IsVisible,IsTargetValid,CanStart, andGetDurationTickslower to generated methods on the definition and are evaluated before creating a run.OnStart,OnUpdate,OnComplete,OnCancel, andOnFaillower to lifecycle methods that receive the activeActivityRun.run.Fail()/run.Complete()/run.Cancel()/run.Stop()are generic lifecycle transitions; Valenar calls such asAdvanceLeadandRevealare host-exposed scope methods.
The generated activity manifest registers both activity definitions in this shape:
public static readonly SecsActivity[] Activities =
[
RecruitGarrisonActivity.Instance,
ScoutNearbyLeadActivity.Instance,
];
Why this shape: One class per activity definition, one active run per execution. The compiler can register definitions once at startup while the runtime can start, tick, complete, cancel, fail, and stop many independent runs.
Notes: None for ScoutNearbyLead source syntax or generated/runtime bridge shape.
Mod slots
Activities are inject/replace-targetable. Wave 6 wires the runtime side of
06-overrides-and-modding.md § 8.10. The architectural identity slots
(activity_actor_scope, activity_target_scope, activity_lane, and
activity_args_schema) are replace-only; inject / try-inject /
inject-or-create against them emits SECS0801 during runtime finalization.
Inject can patch ordinary per-field slots such as duration, cooldown, and
metadata; replace any lifecycle/query body (IsVisible, IsTargetValid,
CanStart, GetDurationTicks, Preview, and the six On* methods); and
replace the full tags / costs lists. The engine-side activity/policy mod
runtime lives under src/SECS.Engine/Modding/; activities flow through
SecsRegistry.RegisterActivityMod(...) plus FinalizeModRegistration()
during host bring-up. After finalize, the registry's
AllActivityDeclarations() returns the merged view.
Activity lanes
An activity lane is a per-actor concurrency domain. An actor has at
most one active ActivityRun per ActivityLaneId. The default lane is
ActivityLanes.Primary; activities that do not override LaneId run on
Primary. The four reserved named lanes — CombatEncounter,
CombatActivity, Movement, Interaction — let later waves model
partial concurrency (movement advancing while a combat activity resolves
on the same actor) without requiring every host site to reinvent its
own busy-tracking dictionary.
Occupancy policy
Each activity declares what happens when its lane is already occupied
on the actor via LaneOccupiedPolicy : ActivityLanePolicy (default
Reject):
| Policy | Behavior on occupied lane |
|---|---|
Reject (default) | StartAt returns null. The caller (Player UI, Policy, Event) is responsible for surfacing the rejection. |
Queue | RESERVED. Throws NotImplementedException. A future wave will define queueing semantics. |
Preempt | RESERVED. Throws NotImplementedException. A future wave will define preemption (cancel existing, start new). |
Per the no-silent-fallback tenet,
an activity that opts into Queue or Preempt MUST surface a clear
runtime error rather than silently degrading to Reject behavior.
Save shape
Lane is saved per run as ActivityLaneId on ActivityRunState.Lane,
and request provenance is saved as ActivityRequestOrigin on
ActivityRunState.Origin. After load, lane occupancy index is rebuilt
from active runs by calling ActivityRunStore.Restore(...) for each
saved ActivityRunState. Hosts MUST call
ActivityExecutor.SeedNextRunId(savedSeed) before restoring runs so
the executor's monotonic counter resumes above every restored run id;
seeding while runs are live throws InvalidOperationException.
Replacement rules
An activity's LaneId is a replace-only architectural slot. Mod
patches that change lane MUST use replace activity, not inject —
changing the lane silently from a partial inject would break the
single-active-per-lane invariant for any actor that already has a
running activity in the old lane. LaneOccupiedPolicy is a runtime
property on SecsActivity, but it is not a committed mod-operation slot
in the Wave 6 schema. If a future source surface exposes it, 06 must add
the slot, merge rule, diagnostics, save/load behavior, and generated/runtime
tests in the same change.
Runtime cross-references
src/SECS.Engine/Activities/ActivityLaneId.cssrc/SECS.Engine/Activities/ActivityLanes.cssrc/SECS.Engine/Activities/ActivityLanePolicy.cssrc/SECS.Engine/Activities/SecsActivity.cs(LaneId,LaneOccupiedPolicy)src/SECS.Engine/Activities/ActivityExecutor.cs(CanStartAt,StartAt)
Activity request and candidate
ActivityRequest and ActivityCandidate are the two value-shapes that
flow between callers, PolicyExecutor, and ActivityExecutor. Their
fields are the stable attribution and candidate-status contract so
replay, analytics, save/load, and policy diagnostics do not infer origin
or candidate state from call-site heuristics.
ActivityRequest
Fields:
| Field | Type | Purpose |
|---|---|---|
ActivityId | ActivityId | Strongly-typed FNV-1a-64 hash naming the activity declaration. |
Actor | EntityHandle | The actor running the activity. |
Target | EntityHandle | The target entity, or EntityHandle.Null for untargeted activities. |
Args | ActivityArgsBlob | Schema-tagged byte payload carrying typed activity args; ActivityArgsBlob.Empty when the activity is untyped. |
Origin | ActivityRequestOrigin | Where the request came from. The executor copies this to ActivityRun.Origin and ActivityRunState.Origin. |
ActivityRequestOrigin values:
| Value | Meaning |
|---|---|
Unknown | Origin was not declared. This is reserved for legacy / best-effort direct executor call sites where no public producer boundary is known. |
Player | The player explicitly initiated this request via the UI or input. |
Policy | A SecsPolicy selected and called this activity. |
System | An ITickSystem or scripted host code initiated the request. |
Event | An event handler (pulse, on-action, or player-choice reaction) initiated the request. |
Mod | A mod runtime injected the request. |
Live public producers that own a boundary stamp origin before start:
Valenar's player queue builds ActivityRequest(..., ActivityRequestOrigin.Player),
PolicyDispatcher.ProcessCall builds ActivityRequest(..., ActivityRequestOrigin.Policy),
and PolicyDispatcher.ProcessCallBest normalizes the selected candidate request to
Policy before invoking the executor. Candidate builders should emit truthful
request origins where they know the producer; a CallBest dispatch still records
Policy on the run because the policy dispatch boundary is the live start origin.
ActivityCandidate
Fields:
| Field | Type | Purpose |
|---|---|---|
Request | ActivityRequest | The bound request the candidate will become if started. |
Preview | EffectPlan? | Read-only preview of effects this activity would produce if started, or null when the activity does not author one. |
PolicyScore | float | Score assigned by PolicyExecutor.ScoreCandidates; 0f for unscored candidates. |
ScoreConfidence | PredictionConfidence | Confidence band on the score (Exact / Probable / Estimated). |
Status | CandidateStatus | Lifecycle status of the candidate (Wave 2). |
CandidateStatus lifecycle:
Available
/ \
Filtered Selected
|
Started
Rejected is a terminal status set when the candidate fails a hard
check at the executor boundary (CanStartAt returning false,
ExpectedArgsSchemaId mismatch, etc.). Filtered is set when the
candidate fails a policy-level eligibility filter and is dropped before
scoring. Selected is set on the winning candidate of CallBest;
Started is set after the executor has produced an ActivityRun.
Collection-backed selectors (runtime note)
When a selector is backed by stored definition references, the runtime
bridge that reads those references and emits ActivityRequests lives on
the C# side of the execution boundary. The source-level design rule
stays generic: definition references in, typed-args ActivityRequests
out. See 10-host-secs-execution-boundary.md § 17.3-17.4 for the
runtime registration and bridge types.
Runtime cross-references
src/SECS.Engine/Activities/ActivityRequest.cssrc/SECS.Engine/Activities/ActivityRequestOrigin.cssrc/SECS.Engine/Activities/ActivityCandidate.cssrc/SECS.Engine/Activities/CandidateStatus.cssrc/SECS.Engine/Policies/PolicyExecutor.cssrc/SECS.Engine/Policies/SelectorSource.cs
Executor invariants
The executor enforces a small set of invariants that no caller can bypass. These are the load-bearing safety guarantees that downstream code (policies, save/load, mods) relies on.
Boundary CanStart re-check
ActivityExecutor.StartAt re-runs CanStartAt immediately before
allocating an ActivityRun. The re-check is mandatory and not
skippable: a candidate that was eligible during scoring may have
become ineligible by the time it is started (cost depleted, target
moved, lane became occupied). When the boundary check fails, StartAt
returns null without invoking any lifecycle hook.
Args schema validation
When an activity declares a non-zero ExpectedArgsSchemaId, the
executor validates the supplied ActivityArgsBlob.SchemaId at the
boundary before any lifecycle hook runs. Mismatches throw
InvalidOperationException rather than passing a wrongly-typed blob
to the activity body — per the no-silent-fallback tenet, lenient
decoding is not allowed. Activities that do not declare a schema
(ExpectedArgsSchemaId == 0) accept any blob, including
ActivityArgsBlob.Empty.
Origin preservation
ActivityExecutor.Start(ActivityRequest, TickContext) and
StartAt(ActivityRequest, TickContext, int) pass request.Origin into
the same core start implementation that allocates ActivityRun. Direct
Start(activityId, context) / StartAt(activityId, context, ...)
overloads delegate explicitly with ActivityRequestOrigin.Unknown and
are best-effort low-level call sites, not the preferred public producer
path for player, policy, system, event, or mod starts.
ActivityRun.ToState() includes Origin, and
ActivityRunStore.Restore(...) reconstructs ActivityRun.Origin from
the saved state. Restore never fabricates provenance from the caller that
loaded the save.
Terminal-hook single-fire guard
Terminal hooks (OnComplete, OnStop, OnCancel, OnFail) run
exactly once per run. The guard is ActivityRun.TerminalLifecycleInvoked,
flipped by MarkTerminalLifecycleInvoked() the first time
InvokeTerminalLifecycle runs for a run. Recursive starts from inside
a terminal hook still see the lane index as released because
ActivityRunStore.NotifyTerminal runs before the hook fires.
Run-id continuity across save/load
ActivityRunId is preserved verbatim across ToState -> Restore. The
host is responsible for calling ActivityExecutor.SeedNextRunId(...)
before restoring runs so the executor's monotonic counter resumes above
every restored run id. Seeding while runs are live throws
InvalidOperationException.
Activity run save/load migration
Wave 8 closes the save/load contract for activity runs with a strict
hard-invalidation policy. There is no engine-side args-migration table;
mismatches between the saved ActivityRunState and the current
registration are fatal for the affected run, and the host is responsible
for cancelling and discarding it.
ActivityRunStore.Restore(activity, state, durationTicks) enforces three
preconditions in order:
| Code | Check | Failure mode |
|---|---|---|
SECS0810 | state.ActivityId matches an activity in the current registry. The caller is expected to look the activity up before calling Restore; supplying a mismatched declaration throws. | Saved run references an activity removed (or renamed) since the save; cancel the run and discard. |
SECS0811 | When the activity declares a non-zero ExpectedArgsSchemaId, state.Args.SchemaId matches it. Activities that omit the override (default 0UL) accept any blob. | Saved blob is from a previous schema; the only safe migration is to cancel the run. Use replace activity to introduce a new schema intentionally. |
SECS0812 | state.Lane.Value != 0 (a lane was declared). | Empty lane on a saved run is illegal; either the save shape is corrupt or a pre-Wave-1 stand-in is being loaded. Cancel and discard. |
The error message names both the saved value and the expected value, and
points the host at the correct response: cancel the saved run, do not
re-add to the runtime store, and (for SECS0811 / SECS0810) use the
replace activity mod operation if the migration is intentional. There
is no engine-side args-transform callback: hosts that need to migrate
args between saves use the mod operation surface, not a runtime hook.
The same hard-invalidation rule extends to slot data:
| Code | Check | Failure mode |
|---|---|---|
SECS0820 | ScopeSlotStore.Restore(snapshots) runs against a fresh, empty store, and every snapshot row carries a non-zero SlotKindId. | Caller tried to interleave Restore with a partially-seeded store, or supplied a snapshot with the zero slot-kind id. |
SECS0821 | Every entry of every snapshot row is non-null and assignable to the snapshot's declared ElementType. | Saved slot type changed between saves (e.g. payload T changed shape); cancel and discard the affected slot. |
Mod-driven invalidation. Mods that modify any architectural slot
(activity_actor_scope, activity_target_scope, activity_lane,
activity_args_schema, policy_actor_scope, policy_domain)
effectively invalidate any saved run that references the previous
identity. Hosts should run a migration sweep after
SecsRegistry.FinalizeModRegistration() to detect and cancel orphaned
runs before resuming the loop. See
06-overrides-and-modding.md § 8.10 and § 8.11.
Policies
A policy declares compile-time AI decision logic for an actor/domain pair. It composes three sub-declarations: need (utility-AI scoring weights), selector (activity candidate sources), and rule Decision (per-tick decision bodies that return a RuleDecision). Wave 4 of the SECS Behavior Refactor introduces policy as the long-promised replacement for the legacy planner keyword (see 09-ai-policies-and-activities.md for the AI scoring math and full open-questions list).
Declaration shell
policy CharacterSurvival
{
actor Character;
domain Survival;
need StayAlive
{
channel = HP_Current;
target = HP_Max;
curve = inverse_quad;
threshold = 50;
weight = 100;
}
selector RestSelector
{
consider activity RestAtCamp from all_registered;
}
rule Decision EmergencyRest()
{
if (actor.resolve(HP_Current) * 4 < actor.resolve(HP_Max))
return call_best(RestSelector);
return continue;
}
}
actor names the scope this policy applies to. domain namespaces it (e.g. Survival, Economy, Combat) so multiple policies can coexist without conflict. need, selector, and rule Decision blocks may appear in any order; the compiler emits them as keyed array fields on the generated SecsPolicy subclass.
Lowering
Each policy lowers to a sealed SecsPolicy subclass under Generated/Policies/. The shape is:
public sealed class CharacterSurvivalPolicy : SecsPolicy
{
public static readonly CharacterSurvivalPolicy Instance = new();
private CharacterSurvivalPolicy() {}
public override PolicyId PolicyId => new(H.Policy_CharacterSurvival);
public override string Name => "Character Survival";
public override ulong ActorScopeId => H.Character;
public override DomainId DomainId => new(H.Domain_Survival);
public override IReadOnlyList<NeedDeclaration> Needs { get; } = new[]
{
new NeedDeclaration(new NeedId(H.Need_StayAlive), "Stay Alive",
H.HP_Current, H.HP_Max, NeedCurveKind.InverseQuad, Threshold: 50, Weight: 100),
};
public override IReadOnlyList<SelectorDeclaration> Selectors { get; } = new[]
{
new SelectorDeclaration(new SelectorId(H.Selector_RestSelector), "Rest Selector",
new ActivityId(H.Activity_RestAtCamp), new SelectorSource.AllRegistered()),
};
public override IReadOnlyList<RuleDeclaration> Rules { get; } = new[]
{
new RuleDeclaration(new RuleId(H.Rule_EmergencyRest), "Emergency Rest", Order: 1),
};
public override RuleDecision EvaluateRule(RuleId ruleId, PolicyRunContext context)
{
if (ruleId.Value == H.Rule_EmergencyRest)
return EvaluateEmergencyRest(context);
return RuleDecision.Continue.Instance;
}
private static RuleDecision EvaluateEmergencyRest(PolicyRunContext context) { ... }
}
PolicyId, DomainId, NeedId, SelectorId, RuleId are strong-typed ulong wrappers over FNV-1a-64 hashes of their canonical id strings (or declared wire id string where the runtime family explicitly owns one), so policy / need / selector / rule references cannot silently confuse each other.
RuleDecision
rule Decision bodies return one of seven cases:
public abstract record RuleDecision
{
public sealed record Continue : RuleDecision { public static readonly Continue Instance; }
public sealed record Complete : RuleDecision { public static readonly Complete Instance; }
public sealed record Fail : RuleDecision { public static readonly Fail Instance; }
public sealed record Wait : RuleDecision { public static readonly Wait Instance; }
public sealed record Call(ActivityId ActivityId, EntityHandle Target, ActivityArgsBlob Args) : RuleDecision;
public sealed record CallBest(SelectorId SelectorId) : RuleDecision;
public sealed record CancelChild : RuleDecision { public static readonly CancelChild Instance; }
}
The no-payload cases are singletons so successive evaluations of the same trivial branch do not allocate. Call and CallBest carry payload — Call dispatches a specific activity directly, CallBest defers to PolicyExecutor.CallBest which builds, scores, and picks the highest-scoring candidate from the named selector.
Selectors and SelectorSource
A selector enumerates the candidate pool for call_best. The SelectorSource sum type is:
AllRegistered— match the selector's boundActivityIdagainst the registry.FromTags(IReadOnlyList<ulong> TagIds)— match every registered activity whoseTagscontains at least one of the listed tag ids.FromCollection(SlotKindId Slot, CandidateBuilderId Builder)— runtime/lowering form used when a selector is backed by stored definition references. The slot carries the typed definition references; the bridge keyed byBuildermaterializes oneActivityRequestper entry. This is C# implementation surface, not standalone.secsvocabulary. See10-host-secs-execution-boundary.md § 17.3-17.4.
The compiler emits the selector source from the source-level from <source> clause. consider activity X from all_registered lowers to new SelectorSource.AllRegistered(); consider activities from tags = Food, Healing lowers to new SelectorSource.FromTags(new[] { H.Tag_Food, H.Tag_Healing }).
Activity declarations use the same committed tag-list spelling as templates:
activity SomeActivity
{
tags = Food;
}
Generated output lowers that list to the activity runtime surface:
public override IReadOnlyList<ulong> Tags => new[] { H.Tag_Food };
The generated H.Tag_* values must equal the plain C# TagId.Create("namespace:tag/name") vocabulary constants, and generated modules must register them with SecsRegistry.RegisterTag(...). The future compiler reports unknown symbolic activity tags as SECS0212; runtime static analysis keeps warning on unknown raw activity tag ids as SECS0410.
Current committed Valenar executable tag provenance covers template tags and the landed activity-tag selector fixture. CharacterSurvival.FoodSelector is present in examples/valenar/Content/policies/character_survival.secs with consider activities from tags = Food, lowers in CharacterSurvivalPolicy to SelectorSource.FromTags(new[] { H.Tag_Food }), and is covered by GeneratedProvenanceTests.GeneratedFoodSelectorMirrorsSecsFromTagsSelector plus CharacterSurvivalPolicyTests.FoodSelectorBuildsOnlyFoodTaggedActivityCandidates. Activity tag source-to-generated coverage is likewise landed through GeneratedProvenanceTests.GeneratedActivityTagsMirrorSecsActivityBodies. This fixture proves committed tag-list lowering and selector candidate building; it does not add new candidate-builder .secs syntax or imply that every CharacterSurvival rule dispatches the food selector.
PolicyExecutor runtime API
The host runs policies through PolicyExecutor. Public surface:
BuildCandidates(policy, selector, actor, tick) → IReadOnlyList<ActivityCandidate>— enumerate the selector's source.ScoreCandidates(candidates, needs, context)— writePolicyScoreandScoreConfidenceinto each candidate based onEffectPlan× need urgency.CallBest(policy, selectorId, context) → ActivityCandidate?— convenience over the two above; returns the highest-scoring candidate or null.EvaluateAllRules(policy, actor, tick) → IEnumerable<(RuleId, RuleDecision)>— iterate all rules inRuleDeclaration.Orderand yield each decision; the host decides what to do with the sequence.
Scoring math lives in NeedCurves (Linear, Quad, InverseQuad, Sigmoid). Each curve maps (current, target, threshold) to a [0, 1] urgency value. See 09-ai-policies-and-activities.md § Utility AI selector for the full scoring formula and the EffectPlan-vs-need channel-touch heuristic.
Registration
Generated SecsPolicy instances live alongside activities in Generated/SecsModule.cs:
public static readonly SecsPolicy[] Policies =
[
CharacterSurvivalPolicy.Instance,
];
The host registers them through registry.RegisterPolicies(SecsModule.Policies) during GameRuntime.Create. Wave 5 lowers compile-time Rules / Needs / Selectors into per-actor mutable slot lists at activation time — see ## Slots below and 09-ai-policies-and-activities.md § Player-authored extensions for the runtime player-edit contract.
Current policy dispatch boundary
The current contract is: hosts invoke registered policies directly through PolicyDispatcher.Tick, Call and CallBest failures return PolicyDispatchResult.Fail, and collection-backed selectors can materialize ActivityRequests from stored definition references without one activity per content row. Per-actor active-policy stacks and multi-domain arbitration remain tracked in docs/design/FUTURE_WORK.md § 1.1.
Policy dispatch loop
PolicyDispatcher is the reference walk that translates each
RuleDecision into the documented side effect against
ActivityExecutor. It tracks the most-recent child ActivityRunId per
(actor, policyId) so CancelChild can target it.
| Decision | Operation | Side effect | Result |
|---|---|---|---|
Continue | advance | none | next rule |
Complete | terminate | none | dispatch returns Complete |
Fail | terminate | none | dispatch returns Fail |
Wait | keep child intact | none | next rule |
Call(activityId, target, args) | dispatch via ActivityExecutor.Start | track child run | if start returns null → return Fail |
CallBest(selectorId) | pick highest-scoring candidate via PolicyExecutor.CallBest, dispatch via ActivityExecutor.Start | track child run | if no candidate → return Fail |
CancelChild | cancel tracked child via ActivityExecutor.Cancel | clear tracked child | next rule |
The walk visits rules in RuleDeclaration.Order. After all rules
return Continue / Wait / CancelChild / a successful
Call / CallBest, the dispatcher returns
PolicyDispatchResult.Continue — the policy has not terminated this
tick and the host is free to invoke Tick again next tick.
Mod slots
Policies are inject/replace-targetable. Wave 6 wires the runtime side of
06-overrides-and-modding.md § 8.11. The two architectural per-field slots
(policy_actor_scope, policy_domain) are closed under inject — a mod
that wants to retarget either uses replace. The three list slots
(policy_needs, policy_selectors, policy_rules) are per-row keyed by
the row's typed id (NeedId / SelectorId / RuleId); existing-id writes
replace the row in place, new-id writes append. policy_evaluate_rule_body
is keyed by RuleId. Mods flow through SecsRegistry.RegisterPolicyMod
plus FinalizeModRegistration(); after finalize, AllPolicyDeclarations()
returns the merged view and PolicyExecutor.CallBest consumes it
transparently.
Slots
Wave 5 introduces SECS.Engine.Slots.ScopeSlotStore as the canonical
home for per-actor mutable lists that are not full entities. Slots are
the engine surface the future template<RuleSlot> /
template<NeedSlot> blocks — plus game-specific reference lists such
as Valenar's template<KnownSpells> example — lower onto.
// examples/valenar/Content/characters/slots.secs
template<RuleSlot> on Character
{
seed_from_policy CharacterSurvival.Rules;
}
template<NeedSlot> on Character
{
seed_from_policy CharacterSurvival.Needs;
}
template<KnownSpells> on Character
{
// Valenar example: empty by default; gameplay appends spell
// definition ids at runtime.
}
Lowered C# (hand-written stand-in until the compiler ships):
public static class CharacterSlots
{
public static readonly SlotKindId Rules = new(H.Slot_PolicyRules);
public static readonly SlotKindId Needs = new(H.Slot_PolicyNeeds);
public static readonly SlotKindId Selectors = new(H.Slot_PolicySelectors);
public static readonly SlotKindId KnownSpells = new(H.Slot_Character_KnownSpells);
public static void SeedDefaults(ScopeSlotStore store, EntityHandle actor, SecsPolicy policy)
{
store.Initialize<RuleDeclaration>(actor, Rules, policy.Rules);
store.Initialize<NeedDeclaration>(actor, Needs, policy.Needs);
store.Initialize<SelectorDeclaration>(actor, Selectors, policy.Selectors);
store.Initialize<TemplateId>(actor, KnownSpells, Array.Empty<TemplateId>());
}
}
Hard rules:
ScopeSlotStoreis a required field onTickContext.SlotStore. EveryTickContextconstruction site provides one; there is no default singleton.Initializethrows if the slot is already seeded for the actor; slot bootstrap is once-per-entity. UseReplace/Append/Insert/RemoveAtto mutate after seeding.Read<T>throws on uninitialized slot reads and on type mismatch.PolicyExecutor.ResolveRules/ResolveNeeds/ResolveSelectorsalso throwInvalidOperationExceptionwhen the actor's slot is not seeded. Hosts MUST seed slots at actor activation (CharacterSlots.SeedDefaultsor equivalent); there is no fall-through topolicy.Rulesetc.- Slot kind ids are FNV-1a-64 of the canonical symbolic name. The
engine-defined kinds (
PolicySlotKinds.Rules,.Needs,.Selectors) useSlotKindId.Create("Slot_PolicyRules")etc. so the generatedH.Slot_PolicyRulesconstant agrees with the runtime kind.
Static analysis (warn-only)
SecsRegistry.Validate() runs a registration-time pass over every registered activity and reports patterns that almost never reflect designer intent. The pass never throws — every diagnostic has Severity.Warning, surfaced through RegistryDiagnostics.Diagnostics. Hosts call Validate() after FinalizeModRegistration() to catch authoring mistakes that the hard-validation phase intentionally permits.
| Code | What fires it | Why warn-only |
|---|---|---|
SECS0410 | activity references an unknown tag id (SecsRegistry.TagExists returns false) | tag tables are rebuilt across mod load orders; an activity may legitimately reference a tag declared in a future-loaded mod |
SECS0411 | activity declares DurationTicks < 0 | hard registration permits any int; runs cannot have negative duration so the row is dead at scheduling time |
SECS0412 / SECS0413 | actor / target scope id has no registered scope declaration | mod patches can rewrite ActorScopeId / TargetScopeId post-Finalize and bypass the per-activity hard-check |
SECS0414 | merged cost row has non-positive amount | RegisterActivity enforces Amount > 0, but mod Costs patches replace the full list and skip the per-element check |
Implementation lives at src/SECS.Engine/Diagnostics/ActivityStaticAnalysis.cs. The pass is purely read-only and idempotent; calling Validate() repeatedly returns the same result so hosts can re-run after each mod-load step.
Cross-references
scope:Xandsave_scope_asinside behavior bodies — covered in05-expressions.md. This doc previews them: events read saved scopes viactx.GetSavedScope(H.Name), and event bodies save them viactx.SaveScope(H.Name, entity). The full expression lowering (includingsave_scope_as name expr, scope walks from saved scopes, scope-to-scope equality, and thescope:name == literalform in conditions) lives in 05.inject/replaceoperations for systems, events, and on-actions — covered in06-overrides-and-modding.md. Systems registered withSystemSource.Secsand events with anyEventEntryare targetable by mod operations;SystemSource.Hostsystems are not.- The underlying tick pipeline shape (phase ordering,
SyncStep,BindingUpdateStep, the order of system vs event vs on-action evaluation within a tick) — use the current engine pipeline sources plus the lowering rules in this doc and05-expressions.md. This doc remains the live contract for that runtime order. ITickSystem,SecsEvent,OnActionDeclaration— current engine types. Sources:src/SECS.Engine/Pipeline/ITickSystem.cs,src/SECS.Engine/Events/SecsEvent.cs,src/SECS.Engine/Events/OnActionDeclaration.cs.EventDispatcher— the dispatch logic for pulse, on-action, and player-choice flows. Source:src/SECS.Engine/Events/EventDispatcher.cs.- Current
SecsActivity/ActivityRun/ActivityExecutor— runtime catch-up for class-based activities. Sources:src/SECS.Engine/Activities/SecsActivity.cs,src/SECS.Engine/Activities/ActivityRun.cs,src/SECS.Engine/Activities/ActivityRunContext.cs,src/SECS.Engine/Activities/ActivityExecutor.cs. - On-action chaining, fallbacks, and saved-scope frames — use this doc together with
05-expressions.md; the class-based activity shape here is the live contract.