Execution Context
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:
| Context | Given to | What it can do |
|---|---|---|
IEvaluationContext | OperatorNode, SourceNode, SelectorNode | Read battle state, connected inputs, actors, variables, event args, and upstream results |
IExecutionContext | MutatorNode, VfxNode, ControlNode, PatternNode | Everything 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:
| Property | Meaning |
|---|---|
Battle | Read-only battle state: piles, units, energy, turn counters |
Host | The unit that owns the running behavior: the card's player unit, the unit the status sits on, or the enemy taking its turn |
Source | The unit that caused the effect: the card's caster or the status applier |
Target | The unit the player selected, when the card or effect is targeted |
Attacker | The attacking unit in damage and reaction contexts |
Card | The card instance currently being played, when the effect runs from a card |
PlayedCard | The card that was just played, in "after a card is played" reactions |
Status | The status definition currently resolving, when the effect runs from a status |
IncomingStatus | The status about to be applied, inside a status-application hook |
Incoming | The 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:

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):
| Method | Use |
|---|---|
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