Event Guide
How to listen to a running battle: typed shortcuts, named subscriptions, custom events, and the refresh pattern that keeps UI correct.
For anyone wiring HUDs, combat logs, achievements, or external systems to GCS battles.Every meaningful moment in a battle raises an event: a card gets played, damage lands, Burn ticks on an enemy, a reward appears. This page shows how to subscribe to those moments from your own code, how to pick the right event for a given UI job, and how to avoid the two classic bugs (leaked subscriptions and stale values).
The event modelโ
Four pieces make up the event surface:
| Piece | Type | Role |
|---|---|---|
| Event names | GCSEventNames | Stable string constants for the 43 built-in events. |
| Payloads | Classes in GCSEventArgs.cs | Typed data delivered with each event. |
| Channel | IGCSEventChannel | The low-level raise and subscribe surface. |
| Facade | GCSApi | Shortcut methods plus generic subscribe helpers. Start here. |
An event tells you that something changed, and roughly what. It is not the source of truth. Use the payload to identify what changed, then read current values from GCSApi; the pattern is spelled out below.
Subscribe with shortcutsโ
Every built-in event except OnEnemyUnitActing has a GCSApi shortcut that binds the correct name and payload type for you:
private IDisposable _energySubscription;
void OnEnable()
{
_energySubscription = GCSApi.OnEnergyChanged(args =>
{
energyLabel.text = $"{args.CurrentEnergy}/{args.MaxEnergy}";
});
}
void OnDisable()
{
_energySubscription?.Dispose();
}
Each shortcut returns an IDisposable. Keep the handle and dispose it when the listener goes away. That is the entire lifetime contract.
Subscribe by event nameโ
Subscribe<TArgs> takes an event name and a typed handler. Use it when you build subscriptions dynamically, listen to custom events, or wire the one built-in event without a shortcut:
private IDisposable _damageSubscription;
void OnEnable()
{
_damageSubscription = GCSApi.Subscribe<DamageEventArgs>(
GCSEventNames.OnDamageDealt,
args => AddCombatLog(args.SourceUnitId, args.TargetUnitId, args.Amount));
}
The parameterless overload covers events that carry no payload:
private IDisposable _shuffleSubscription;
void OnEnable()
{
_shuffleSubscription = GCSApi.Subscribe(
GCSEventNames.OnDrawPileShuffled,
RefreshDrawPileWidget);
}
OnEnemyUnitActing always goes through this path, since it has no shortcut. The demo listens to it to play the attacker's lunge toward the player:
_actingSubscription = GCSApi.Subscribe<EnemyUnitActingEventArgs>(
GCSEventNames.OnEnemyUnitActing,
args => PlayAttackWindup(args.UnitId, args.Intent));
The channel dispatches on the pair of event name and argument type. A typed handler with the wrong TArgs never fires, and subscribing one event name with two different argument types logs a [GCSEvents] error in the Console and ignores the second subscription. When in doubt, use the shortcuts or check the Event Reference.
Pick the right eventโ
| UI or system need | Good events |
|---|---|
| Refresh the full battle HUD | OnBattleStarted, OnTurnStarted, OnTurnEnded, OnEnemyTurnStarted, OnBattleEnded |
| Refresh energy | OnEnergyChanged, OnEnergyGained, OnEnergySpent |
| Refresh hand cards | OnCardDrawn, OnCardDiscarded, OnCardExhausted, OnCardRetained, OnCardAddedToHand, OnCardModified |
| Refresh unit HP and armor | OnUnitHpChanged, OnArmorGained, OnArmorLost, OnUnitDied |
| Refresh status widgets | OnStatusChanged, OnStatusTicked, OnStatusInflicted, OnStatusExpired, OnStatusRemoved |
| Refresh enemy intents | OnEnemyUnitIntentChanged, OnEnemyPhaseChanged |
| Play attack wind-ups on acting enemies | OnEnemyUnitActing |
| Show choices | OnEffectChoiceOffered, OnEffectChoiceSelected, OnEffectChoiceSkipped |
| Show rewards | OnBattleRewardOffered, OnBattleRewardSelected, OnBattleRewardSkipped |
| Log combat details | OnCardPlayed, OnDamageDealt, OnDamageTaken, OnStatusInflicted, OnUnitDied |
Read state after the eventโ
For UI, treat the event as a trigger and GCSApi as the data source:
- The event fires.
- Use the payload to identify the affected unit, card, or panel.
- Read current values through
GCSApi. - Update the UI.
private void OnUnitHpChanged(UnitHpChangedEventArgs args)
{
var unit = FindUnitById(args.UnitId);
hpBar.SetValue(GCSApi.Hp(unit), GCSApi.MaxHp(unit));
armorLabel.text = GCSApi.Armor(unit).ToString();
}
A single graph resolution can raise several events back to back. Reading fresh state after each one keeps the UI correct no matter how many fired or in what order.
When the payload alone is enoughโ
One-shot effects need only the payload:
| Event | Payload-only use |
|---|---|
OnDamageDealt | Spawn a damage number or a combat log row. |
OnDamageTaken | Show a hit reaction on the target. |
OnArmorGained | Show an armor gain number. |
OnEnergyGained | Show an energy pulse. |
OnEffectChoiceOffered | Render the choice candidates from the payload. |
OnBattleRewardOffered | Render the reward cards from the payload. |
Even then, follow the one-shot effect with a state refresh from GCSApi, so the bar underneath the floating number lands on the right value too.
Subscription lifetimeโ
Subscribe once, dispose once. Good homes for that pair:
OnEnable/OnDisable- a view model's
Initialize/Dispose - scene controller setup and teardown
Places that leak:
Update- every button render
- every hand rebuild that never disposes the previous subscriptions
A handler running two or three times per event is the classic sign of resubscribing without disposal.
Events without a payloadโ
Four built-in events carry no data at all:
OnBattleStartedOnBattleRewardSkippedOnEffectChoiceSkippedOnDrawPileShuffled
Subscribe to these with a plain Action:
_sub = GCSApi.OnDrawPileShuffled(RefreshPiles);
Custom named eventsโ
GCSApi.RaiseGameEvent raises an event under any name you choose, with an optional argument dictionary. The FlowGraph node Raise Internal Event does the same thing from inside a graph.
GCSApi.RaiseGameEvent("PlayerMarkedTarget", new Dictionary<string, object>
{
["TargetId"] = target.UnitId
});
Subscription shape must match raise shape. A raise with an argument dictionary arrives typed as IReadOnlyDictionary<string, object>; a raise without arguments arrives on the parameterless path:
_sub = GCSApi.Subscribe<IReadOnlyDictionary<string, object>>(
"PlayerMarkedTarget",
args => HighlightUnit((int)args["TargetId"]));
Custom events also wake any On Internal Event entry with the same name inside card, status, and enemy graphs, so authored content can react to a moment your code invented.
Reach for custom names only when no built-in event describes the moment. Duplicating a built-in state event under a custom name buys nothing and splits your listeners in two.
Watch the channel in the Monitorโ
The Event tab of the Game Card Monitor shows the channel live. The GCS Channel section lists every broadcast in order, and Dispatcher Probes prints each dispatch with its subscriber count. Together they split "my listener never ran" into its two real cases: the event never fired, or it fired with subs 0.

The GES bridgeโ
Projects that already run on Game Event System can forward battle moments into GES and start graph entries from GES raises. That path has its own page: GES integration. For behavior that stays inside GCS, GCSApi.Subscribe and GCSApi.RaiseGameEvent remain the simpler tools.
Troubleshootingโ
| Symptom | Check |
|---|---|
| Handler fires two or more times per event. | You resubscribed without disposing the previous handle. |
| Handler never fires. | Event name and payload type must both match; see the Event Reference. Also look for a [GCSEvents] argument-type error in the Console. |
| UI shows stale values. | Refresh from GCSApi after the event instead of caching payload values. |
| Card buttons stay enabled during a choice. | Gate them on GCSApi.IsWaitingForChoice. |
| Reward UI never closes. | Finish the flow with GCSApi.ApplyReward or GCSApi.SkipReward. |
| GES listeners receive nothing. | The bridge must be installed and the event picked on the node; see GES integration. |