Skip to main content

Execution Context

Guide

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

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 produces the editor rule you configure: a connected port wins, and an unconnected port falls back to the value on the node

Use PullInput<T>(this, "PortName") only when default(T) is a sensible fallback; for fields shown on the node, 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; direct writes to HP, statuses, piles, or card state bypass the hooks, events, and presentation updates triggered by the controller path:

The Monitor Unit tab shows Poison stacks applied through ctx.Controller.ApplyStatus; a direct field write would bypass this panel, status Hooks, runtime events, and the unit's status widget:

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; the raising and responding graphs must use the same argument name; keep it stable and descriptive

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