Skip to main content

Execution Context

GCS ยท Extending GCS

Everything a custom node touches goes through ctx: wired inputs, resolved targets, contextual actors, controller mutations, published results, hooks, and variables.

For programmers writing node Execute, Evaluate, Play, or DecideNext code.

The execution context is the supported way for custom nodes to read the current battle, consume connected port values, resolve targets, mutate state, publish results, and participate in hooks. Use it instead of reaching around the runtime; that is what keeps cards, statuses, enemies, events, Monitor output, and presentation consistent with each other.


Read-only and writable contextsโ€‹

GCS hands out two context interfaces, matched to the node family:

ContextGiven toWhat it can do
IEvaluationContextOperatorNode, SourceNode, SelectorNodeRead battle state, connected inputs, actors, variables, event args, and upstream results.
IExecutionContextMutatorNode, VfxNode, ControlNode, PatternNodeEverything above, plus battle mutations, result publishing, hook writes, cancellation, and variable writes.

A node that only computes a value gets the read-only context and must not mutate battle state. A node that changes the battle routes every change through IExecutionContext.Controller.


Pull connected inputs firstโ€‹

When a scalar field can be exposed as a port, read it with PullInputOr:

public int Amount = 5;

public override void Execute(IExecutionContext ctx)
{
int amount = ctx.PullInputOr(this, nameof(Amount), Amount);
}

This gives the author the behavior they expect: a connected port wins, an unconnected port falls back to the field value typed on the node. Use PullInput<T>(this, "PortName") only when default(T) is a sensible fallback; for author-facing fields, PullInputOr is clearer.


Resolve units and cardsโ€‹

Pair source fields with the context helpers for target and card selection:

public UnitSource TargetSource = UnitSource.Opponent;
public CardSource CardsSource = CardSource.HandPile;

public override void Execute(IExecutionContext ctx)
{
foreach (var target in ctx.ResolveUnits(this, TargetSource, nameof(TargetSource)))
{
if (target == null || target.IsDead) continue;
ctx.Controller.DealDamage(target, 4, ctx.Source);
}

var cards = ctx.ResolveCards(this, CardsSource, nameof(CardsSource));
}

ResolveUnits and ResolveCards honor both authoring modes: the dropdown value on the node (Self, Opponent, AllEnemies, AllUnits; ThisCard, HandPile, DrawPile, DiscardPile, ExhaustPile, AllPiles), or an exposed and connected port that overrides it. Use ResolveUnit or ResolveCard when the behavior should act on the first result only.

A node can also consume a unit set produced by another node without owning a UnitSource field at all: declare a unit input with NodePort.In("Units", PortDataType.UnitRefOrCollection) and read it with ctx.PullTargets(NodeId, "Units"), an IExecutionContext member. The result is empty when nothing is connected.


The actors on the contextโ€‹

The context exposes the actors that make a card or status natural to write:

PropertyMeaning
BattleRead-only battle state: piles, units, energy, turn counters.
HostThe unit that owns the running behavior: the card's player unit, the unit the status sits on, or the enemy taking its turn.
SourceThe unit that caused the effect: the card's caster or the status applier.
TargetThe unit the player selected, when the card or effect is targeted.
AttackerThe attacking unit in damage and reaction contexts.
CardThe card instance currently being played, when the effect runs from a card.
PlayedCardThe card that was just played, in "after a card is played" reactions.
StatusThe status definition currently resolving, when the effect runs from a status.
IncomingStatusThe status about to be applied, inside a status-application hook.
IncomingThe value a modification hook may change: the raw damage, heal, or armor amount before it lands.

Null-check these when a node can run from more than one entry type. A node used by both cards and statuses cannot assume ctx.Card is present.


Mutate through the controllerโ€‹

ctx.Controller is the single gateway for changing battle state:

ctx.Controller.DealDamage(target, amount, ctx.Source);
ctx.Controller.GainHp(target, amount);

var burn = ctx.ResolveStatusId("Burn");
if (burn != null) ctx.Controller.ApplyStatus(target, burn, stacks, ctx.Source);

Damage, healing, armor, statuses, card movement, energy, and internal events all go through controller calls. Never write HP, statuses, piles, or card state on model objects directly. The controller path is what triggers the rest of the system:

State changed through the controller is state every tool can see. In the Monitor's Unit tab below, the Poison stacks on the enemy arrived through a controller ApplyStatus call; a direct field write would leave this panel, the status hooks, and the unit's status widget blind:

Monitor Unit tab showing the player&#39;s HP, armor, and energy next to an enemy carrying Poison stacks applied through the controller


Publish outputsโ€‹

A mutator that declares a result output publishes the value with StoreResult:

public IEnumerable<NodePort> DeclarePorts()
{
yield return NodePort.ResultOut("DamageDealt", PortDataType.Int);
}

public override void Execute(IExecutionContext ctx)
{
int dealt = 0;
// deal damage...
ctx.StoreResult(this, "DamageDealt", dealt);
}

Downstream nodes normally read the value through a connection with PullInputOr. ctx.GetResult<T>(producerNodeId, outputPortName) exists for advanced cases where you deliberately address a known producer node by id.


Modify or cancel values in hooksโ€‹

Graphs running from hook entries can adjust or cancel an incoming value:

public override void Execute(IExecutionContext ctx)
{
if (ctx.Incoming <= 0) return;
ctx.WriteHookResult(ctx.Incoming + 2);
}

WriteHookResult(value) replaces the incoming number; CancelHook() stops the pending action entirely. GCS ships 18 hook entries covering damage dealt and taken, attacks taken, HP gain and loss, armor gain, loss, and reset, energy gain, spend, and per-turn amount, status infliction and receipt, status damage, card cost, card playability, draw count, and hand size, so the same two calls cover effects like:

  • increase damage dealt;
  • reduce damage taken;
  • make the next card free;
  • prevent a status from being applied;
  • change how many cards a turn draws.

Only call hook methods from graphs that actually run inside hook entries. Outside a hook they change nothing the player can see.


Variablesโ€‹

Variables carry a value from one part of a graph to a distant one:

ctx.SetVariable("Combo", battleScope: true, value: combo + 1);
int combo = ctx.GetVariable<int>("Combo", battleScope: true);

With battleScope: false the variable lives only for the current effect run. With battleScope: true it survives across cards and turns for the whole battle. An unset variable reads back as default(T).

Prefer result outputs when the value should travel through visible graph connections; prefer variables when several distant parts of a graph need the same named state.


Event argumentsโ€‹

Graphs triggered by an On Internal Event entry can read the arguments attached by the matching Raise Internal Event node:

object raw = ctx.GetEventArg("Amount");
int amount = raw is int value ? value : 0;

Outside an event response, or when no argument with that name was sent, GetEventArg returns null. Argument names are a contract between the raising graph and the responding graph, so keep them stable and obvious.


Definition lookupsโ€‹

The context resolves project definitions from identifiers stored on the node (a GUID or a display name):

MethodUse
ResolveStatusId(string statusId)Find a status definition.
ResolveCardId(string cardId)Find a card definition.
ResolveEnemyUnitId(string unitId)Find an enemy unit definition.
StatusStacksOf(unit, statusId)Read a unit's stack count for a status.
HasStatusOf(unit, statusId)Check whether a unit has at least one stack.

For ordinary authoring, the built-in selector and status nodes already cover these. Use the helpers in custom nodes when the custom behavior itself needs a direct lookup.


A safe mutator patternโ€‹

Most custom mutators follow this shape (with Total declared via NodePort.ResultOut in DeclarePorts):

public override void Execute(IExecutionContext ctx)
{
int amount = ctx.PullInputOr(this, nameof(Amount), Amount);
if (amount <= 0) return;

int total = 0;
foreach (var target in ctx.ResolveUnits(this, TargetSource, nameof(TargetSource)))
{
if (target == null || target.IsDead) continue;
ctx.Controller.GainHp(target, amount);
total += amount;
}

ctx.StoreResult(this, "Total", total);
}

The pattern keeps the authored node predictable: connected values override fields, both target modes work, dead or missing units are skipped, every change goes through the controller, and outputs land on declared ports.