Custom Nodes
A custom node is a plain runtime C# class: subclass a node family, decorate it with [FlowNode], and it appears in the Add Node menu after Unity compiles; no registration call, no editor code
The whole extension surface lives in TinyGiants.GCS.Runtime; your class never references TinyGiants.GCS.Editor, and GCS discovers it by reflection from any loaded assembly that references the runtime:
File and assembly placement
Put node files in one of your own runtime assemblies that references TinyGiants.GCS.Runtime; an editor-only assembly is excluded from player builds, so a node stored there cannot execute at runtime; the FlowGraph editor reads correctly placed runtime nodes through the public runtime API without requiring editor dependencies in your code
The fastest start is the shipped sample folder:
Assets/TinyGiants/GameCardSystem/Samples~/CustomNodes/
Copy a sample into your project, then rename the namespace, class, category, display name, and fields; use Custom Node Examples to choose the closest starting file and adapt each part safely
A working node needs exactly three pieces:
| Requirement | Why it matters |
|---|---|
[Serializable] | Unity serializes the node instance inside the owner's FlowGraph |
[FlowNode(category, displayName)] | Registers the node under a group in the Add Node menu |
| A public node base class | Decides what the node does when the graph reaches or reads it |
using System;
using TinyGiants.GCS.Runtime;
namespace YourGame.Cards
{
[Serializable]
[FlowNode("Action", "Gain Armor To Self")]
public sealed class GainArmorToSelfNode : MutatorNode
{
public int Amount = 1;
public override void Execute(IExecutionContext ctx)
{
int amount = ctx.PullInputOr(this, nameof(Amount), Amount);
if (amount <= 0 || ctx.Host == null) return;
ctx.Controller.GainArmor(ctx.Host, amount);
}
}
}
Pick the node family
Choose the base class that matches the behavior you want to expose in FlowGraph, not the helper code used to implement it
| Base class | Override | Use it for |
|---|---|---|
MutatorNode | Execute(IExecutionContext ctx) | Battle-state changes: damage, healing, armor, statuses, card movement, energy, variables |
OperatorNode | Evaluate(IEvaluationContext ctx, string outPort) | Pure values: math, comparisons, conversions, string or number results |
SourceNode | Evaluate(IEvaluationContext ctx, string outPort) | Reading context data: current turn, source unit, played card, status stacks |
SelectorNode | Evaluate(IEvaluationContext ctx, string outPort) | Producing unit or card selections that other nodes consume |
ControlNode | DecideNext(IExecutionContext ctx) | Routing the flow to one or more named branches |
PatternNode | DecideNext(IExecutionContext ctx) | Enemy targeting patterns that pick the next intent branch |
VfxNode | Play(IExecutionContext ctx) | Presentation only: effects, camera impulses, UI beats |
Most gameplay nodes end up as MutatorNode or OperatorNode; if something in the battle changes, it is a mutator; if the node exists only to feed a value into another node, it is an operator
These families are the same ones the demo content runs on; Mire Reaper, a Hunter-scene enemy, drives its whole turn from pattern nodes that pick an intent branch, and every branch ends in the action and FX nodes a custom MutatorNode or VfxNode sits beside:

Name the result shown in Add Node
The [FlowNode] category is the Add Node menu group; reuse a built-in group when your node belongs next to built-in behavior: Action for state-changing effects, Operator for calculated values, Get for context reads and selections, Flow for branching, FX for presentation, Intent for enemy intent patterns; any other category string creates a group of its own, which is the right call for a family of game-specific nodes
The display name should describe the result available in the graph (Heal If Below Half), not the helper you wrote (ConditionalHpRestoreUtil); the Add Node menu should tell you what the node does before you place it
Fields are the authoring UI
Public fields become the controls you see and edit on the node:
public UnitSource TargetSource = UnitSource.Self;
public int HealAmount = 5;
public bool OnlyBelowHalf = true;
GCS infers ports from those fields:
- Every public
int,float,bool, orstringfield can be exposed as a scalar input port - A
UnitSourcefield named<Name>Sourceexposes a unit input named<Name>, soTargetSourcegives aTargetport - A
CardSourcefield named<Name>Sourceexposes a card input the same way - Any other public field type (an enum, a prefab, a color) renders as a configuration row on the node body, not as a port
You choose which eligible fields to expose on each node in the FlowGraph editor; until a field is exposed and wired, it stays a plain value on the node body
Read scalar fields through ctx.PullInputOr(this, nameof(Field), Field); a wired port then overrides the node value, while an unwired port uses it as the fallback
Route every change through the controller
Inside MutatorNode.Execute, route every battle change through ctx.Controller; direct edits to UnitState, CardInstance, piles, HP, statuses, or energy bypass hooks, events, and presentation synchronization
public override void Execute(IExecutionContext ctx)
{
int amount = ctx.PullInputOr(this, nameof(HealAmount), HealAmount);
if (amount <= 0) return;
foreach (var target in ctx.ResolveUnits(this, TargetSource, nameof(TargetSource)))
{
if (target == null || target.IsDead) continue;
ctx.Controller.GainHp(target, amount);
}
}
Controller calls are what keep runtime events, status hooks, Monitor output, and the presentation layer in sync; A direct field write skips all of that, and the result is a card that "works" in the numbers but fires no events, no visuals, and no reactions
Publish a result other nodes can read
If a downstream node should read a value your node produced, implement INodePorts and declare a result output
using System;
using System.Collections.Generic;
using TinyGiants.GCS.Runtime;
[Serializable]
[FlowNode("Action", "Count Living Targets")]
public sealed class CountLivingTargetsNode : MutatorNode, INodePorts
{
public UnitSource TargetSource = UnitSource.AllEnemies;
public IEnumerable<NodePort> DeclarePorts()
{
yield return NodePort.ResultOut("Count", PortDataType.Int);
}
public override void Execute(IExecutionContext ctx)
{
int count = 0;
foreach (var target in ctx.ResolveUnits(this, TargetSource, nameof(TargetSource)))
if (target != null && !target.IsDead) count++;
ctx.StoreResult(this, "Count", count);
}
}
The name in NodePort.ResultOut("Count", ...) must match the name passed to ctx.StoreResult(this, "Count", value)
In the editor, StoreResult logs a warning when the port name matches no declared output; A misspelled name shows up as a Console message instead of a value that silently goes nowhere
Treat names as saved-content contracts
Once real graphs use your node, its class name, field names, and declared port names are contracts baked into saved content; rename them only when you also migrate the FlowGraph assets that reference them
Good custom nodes are small and literal:
- One node exposes one clear gameplay job
- Field names describe the visible choice:
HealAmount,TargetSource,OnlyBelowHalf - Output names describe the value:
Healed,DamageDealt,Count - Put any easy-to-misread rule in the field name or tooltip
Compile and find it
- Let Unity compile with no errors
- Open any GCS FlowGraph
- Open the Add Node menu and search for your
[FlowNode]display name - Add the node, expose the ports you need, and run a battle with the Monitor open
If the node does not appear, check that the class is concrete (not abstract), [Serializable], decorated with [FlowNode], and compiled in a runtime assembly that references TinyGiants.GCS.Runtime