Findings  ›  TcgEngine study

TcgEngine (Unity asset) — how it works, what to steal

Study of the purchased Unity TCG template at D:\Repos\For-Context__Card-Game-Unity (the commercial TcgEngine asset: a Hearthstone-style engine, 228 C# files + data assets). Goal: verify that every mechanic it implements — actions, effects, modifiers, traits, statuses, conditions — is wired in DeckCraft when it makes sense. The Decision column in the findings table is deliberately empty; nothing gets built until decided. Cross-checked against the official docs (2026-08-06): they confirm the code reading — and their sanctioned extension pattern is “inherit EffectData/ConditionData in code”, i.e. their escape hatch is code inheritance where ours is the catalog handler pointer; data stays the default in both. Docs also confirm: equipment max 1 per character (dies with host), secrets fire only on the opponent's turn, activated abilities without exhaust are repeatable per turn, and chain_abilities is their idiom for splitting complex abilities into sequential parts.

covered — we have the equivalent gap — fix proposed idea — feeds an existing workstream ceiling — only if a game demands it

What it is

A complete 1v1 Hearthstone-like: heroes with 20 HP, mana that ramps (max grows +1 per turn, refills full each turn), 5 board slots per side, creatures with attack/HP, spells, equipment, secrets. Server- authoritative with a Unity client; the whole game state (Game/Player/Card) is plain serializable data, deep-clonable — which is exactly what lets its minimax AI simulate ahead. Everything gameplay-related is data-driven: cards, abilities, effects, conditions, filters, statuses and traits are all ScriptableObject assets wired together by reference — the C# classes are tiny single-purpose primitives, the assets are the authoring layer. Structurally that is the same bet DeckCraft made (catalog JSON + generic engine), just with Unity's inspector as the form UI.

LayerWhereCount
Data model (ScriptableObject classes)Scripts/Data/ — CardData, AbilityData, EffectData, ConditionData, FilterData, StatusData, TraitData, GameplayData…21 classes
Effect primitivesScripts/Effects/30
Condition primitives + target filtersScripts/Conditions/23 + 4
Rules engineScripts/GameLogic/ — GameLogic.cs (executor), Game.cs (state + legality), ResolveQueue
Authored content (assets)Resources/ — 77 cards, 113 abilities, 42 effect instances, 62 condition instances, 20 statuses, 4 traits
Meta-game (out of sim scope)Packs, rarities, variants, decks, levels, rewards, sell ratio

How a card is wired

The chain is Card → Ability[] → (trigger + trigger-conditions + target + target-conditions + filters + effects[] + status[] + value + duration + chain-abilities). One AbilityData shape covers everything: battlecries, auras, activated abilities, traps, choices.

  • Card = id, type (Hero/Character/Spell/Artifact/Secret/Equipment), team, rarity, mana/attack/hp, traits[] (plain id labels), stats[] (trait+int pairs — free-form named values), abilities[], art/FX.
  • Trigger (when): Ongoing, Activate, OnPlay, OnPlayOther, StartOfTurn, EndOfTurn, OnBeforeAttack, OnAfterAttack, OnBeforeDefend, OnAfterDefend, OnKill, OnDeath, OnDeathOther.
  • Target (who): Self, PlayerSelf/PlayerOpponent/AllPlayers, AllCardsBoard/Hand/AllPiles, AllSlots, AllCardData (the whole design catalog — for create/discover), PlayTarget (chosen on play), SelectTarget / CardSelector / ChoiceSelector (pause & ask the player), EquippedCard, AbilityTriggerer, LastPlayed / LastTargeted / LastDestroyed / LastSummoned.
  • Conditions: two lists — conditions_trigger (may this fire at all?) and conditions_target (is this candidate valid?). 23 reusable primitives with comparison operators: card type/team/trait, stat vs value, has-status, pile location, count-of-matching-cards, damaged, equipped, once-per-turn, is-own-turn, slot distance/range, dice roll… Authored once as small assets (is_wolf, once_per_turn) and reused across abilities.
  • Filters (after conditions): FilterFirst(N), FilterRandom(N), FilterHighestStat, FilterLowestStat.
  • Effects: the 30 primitives — damage, heal, draw, discard, summon, create, transform, destroy, send-to-pile, add/set/reset stat, add/remove trait, add/remove ability, mana, attack/redirect-attack, exhaust, change owner, shuffle, roll dice… Magnitude almost always comes from the ability's single value int (and duration for statuses) — not from per-effect params.
  • Status[]: the ability can also apply keyword statuses (see below) with that value/duration.
  • chain_abilities[]: follow-up abilities queued after this one resolves — also reused as the options of a “choose one:” (ChoiceSelector).
  • Activated abilities add mana_cost + exhaust (tap).

Concrete sampled assets, to see the grain of it:

AssetReads asWiring
spells/spell_damage3“Deal 3 damage.”trigger OnPlay · target PlayTarget · conditions [is_not_empty, ai_is_enemy] · effects [damage] · value 3. The damage effect adds the caster's spell_damage trait value as bonus.
ongoing/aura_wolf“Other allied wolves get +1 attack.”trigger Ongoing · target AllCardsBoard · conditions [is_character, is_wolf, is_not_self, is_ally] · effects [add_attack] · value 1. is_wolf is a trait-condition asset.
other/turn_green_heal“At the start of each turn, heal allied green cards 3.”trigger StartOfTurn · target AllCardsBoard · conditions [is_green (team), is_ally] · effects [heal] · value 3.
spells/spell_paralyse3“Paralyse a character for 3 turns.”trigger OnPlay · target PlayTarget · conditions [is_character, is_enemy] · effects [] · status [paralysed] · duration 6 (half-turn ticks).
activated/activate_fire“2 mana, tap: deal damage.”trigger Activate · target SelectTarget · mana_cost 2 · exhaust · conditions_trigger [once_per_turn].

Neat trick: ai_is_enemy is an AI-only condition (always true for humans, restricts bots) so the minimax never wastes a damage spell on its own creature. Data-level bot guardrails.

Engine internals

SystemHow it works
Turn flowOnly 3 phases: StartTurn → Main → EndTurn. StartTurn: draw 1, mana_max += 1 (cap 10), mana = mana_max, poison ticks, un-exhaust (unless Sleep), fire StartOfTurn abilities. EndTurn: status durations decrement, EndOfTurn abilities, next player.
ResolutionFIFO queue, not a stack (ResolveQueue): four queues drained in priority order — abilities > secrets > attacks > callbacks. No interrupts; player commands received while draining are deferred until the queue is empty. If an ability needs a player choice, resolution pauses (selector state on Game), the client answers with a Select* command, resolution resumes. No chain-depth cap — bounded only by authored data.
Ongoing / aurasUpdateOngoing() after nearly every state change: full clear-and-recompute. Wipe every card's attack_ongoing/hp_ongoing/mana_ongoing + ongoing statuses/traits/abilities, then re-apply every Ongoing-trigger ability whose conditions still hold. Final stats are read as base + ongoing − damage. Also the state-based cleanup point (dead cards discarded, orphaned equipment).
CombatAttack declared → OnBeforeAttack/OnBeforeDefend + secrets → mutual simultaneous damage (defender counters unless attacker has Intimidate) → exhaust attacker (Fury = one extra attack) → OnAfterAttack/OnAfterDefend → deaths trigger OnKill / OnDeath / OnDeathOther. Damage pipeline checks Invincibility, SpellImmunity, Shell (negate one hit), Armor (flat reduction), Trample (overflow to player), LifeSteal, Deathtouch.
Statuses20-keyword closed enum, gained/lost at runtime with value + duration (0 = permanent): AddAttack/AddHP/AddManaCost, Stealth, Invincibility, Shell, Protection (taunt) / Protected, Armor, SpellImmunity, Deathtouch, Fury, Intimidate, Flying, Trample, LifeSteal, Silenced, Paralysed, Poisoned, Sleep. Checked inline in the combat/legality code; durations tick down at end of turn; taunt propagates Protected to allies each recompute.
SecretsFace-down cards in a secret pile; on each trigger event the opponent's secrets are scanned, max one fires per event, then it's discarded. That is their entire reactive layer.
BoardSlots (x 1–5, y rows, p side), one card per slot, own-side placement, distance/range helpers for positional conditions. Movement exists but is disabled in the demo.
Global memoryEngine registers effects/conditions can reference: last_played, last_destroyed, last_targeted, last_summoned, ability_triggerer, rolled_value, ability_played (once-per-turn set).

The AI

Minimax with alpha-beta pruning over cloned game state, run on a background thread, one best action executed per search. Depth 3 turns; heavily pruned: max 2–3 sequential actions per simulated turn, max 4–7 candidate actions per step (cut by a quick action score), plus an ordering trick (spells → abilities → attacks → plays) so permutations of the same action set are not re-explored. Leaf heuristic: win/loss ±100000 (adjusted to prefer fast wins), player HP ×4, board attack ×3, board HP ×2, +20 per board card, +5 per hand card… and — the part worth copying — each Status asset carries an hvalue AI weight (taunt +1, paralysed −1) multiplied ×15, so new content tunes the bot from data. Difficulty levels 1–10 are just noise injection into the heuristic (level 10 = 0 noise, level 1 = ±200) — weak bots blunder naturally instead of following dumber rules.

Concept mapping — TcgEngine ↔ DeckCraft

TcgEngineDeckCraft equivalentVerdict
Traits (id labels) + Teams (fire/forest…)tags[] (+ family axis) → all_tags runtime viewcovered
TraitStat (trait + int value)Free-form attributes + counters + engineEffectiveAttributecovered
Ability anatomy (trigger/target/conditions/filters/effects/value)Card effects: trigger preset or pattern · subject block · zones · ƒx paramscovered structurally — gaps are the finding rows below
OnPlayOther / OnDeathOther / StartOfTurn triggersEvent bus pattern triggers (card_moved, turn_started…)covered
Ongoing auras (clear-and-recompute push)modifiers[] + active_while (compute-on-read) — same semantics, cheaper mechanismcovered
ResolveQueue + pause-for-selectorflow.stepQueue + decisions (bots answer inline; awaitingDecision scaffold)covered
GameplayData config assetgame_meta + setup_speccovered
Clonable state for AI simulationSerializable state + seeded determinism (replay)covered — enables U12
conditions_trigger / conditions_target (23 primitives)Sets + named conditions (built 2026-08-06)U1 shipped
Statuses (runtime keywords, value + duration + expiry)CardInstance.statuses[] — dynamic behaviours, all gates status-awareU2 shipped
Activated abilities (cost + tap)trigger activated + activation {cost, exhaust} + activate_ability verbU3 shipped
Combat lifecycle triggers + keyword combat pipelineBus events attack_declared/resolved + card_killedU4 shipped
ChoiceSelector (“choose one:”)choose_one mechanic (logged decision)U5 shipped
Filters first/random/highest/lowestspec select: first|random|highest|lowestU6 shipped
last_played / last_destroyed / ability_triggerer registers{source: "event_card"} card targetingU7 shipped
Create / Summon / Transform from the design catalogcreate_card mechanic — runtime tokensU8 shipped
Dice roll + rolled-value conditionsroll_dice + rolled_value opU9 shipped
Equipment / attach (bearer relation)No card-to-card attachmentU10
Secrets / traps (face-down reactive)Pattern triggers only fire for in-play cardsU11
Board slots / positioningZones are unordered — same ceiling as Clank!U13
Minimax + hvalue-in-data + noise levelsGreedy bots; bot quality is the permanent workstreamU12
Packs / rarities / variants / levels / sell ratioCollection meta-game — out of simulator scope todayout of scope

Findings — proposed wirings

IDMechanic (TcgEngine)Proposed DeckCraft wiringEffortStatusDecision
U1Reusable conditions on abilities: conditions_trigger (may it fire?) + conditions_target (valid candidate?), authored once, referenced everywhere Exactly the parked Sets + Conditions plan — this study validates it. Their 23-primitive catalog is our starter preset list for named conditions: stat-compare, has-status/behaviour, pile/zone location, count-compare, damaged, once-per-turn, is-own-turn, has-trait/tag. Build the plan as designed; seed the condition preset catalog from this list. parked plan (~2d)shipped 2026-08-06 — built as designed; preset catalog seeded from this list; proof combo fires 20/20 in the verification kingdombuild
U2Statuses: runtime keyword states with value + duration, gained/lost by effects, expiring at end of turn, with engine semantics (taunt, stealth, armor, silence, poison…) Generalize behaviours into a runtime tier: CardInstance.statuses[] {behaviour, value, duration}; effects gain apply_status/remove_status; durations tick in turn_ended; engine gates already consult behaviours (Outpost precedent) so dynamic statuses flow through the same checks; behaviour: filter criterion already matches. Poison/regen = status + pattern-triggered adjust (already expressible once statuses exist). ~2dshipped 2026-08-06 — statuses grant behaviours; all gates/filters status-aware; apply/remove effects; turn-end expirybuild
U3Activated abilities: per-card action with mana_cost + exhaust (tap), once-per-turn via condition Already tracked as S2/Q1 (Star Realms bases). TcgEngine gives the reference shape: effect trigger activated + cost {tracker: amount} + exhaust flag; legality = card in play + cost payable + not exhausted; surfaces as a legal action like custom actions do. Compose with uses_per_turn bindings. ~2dshipped 2026-08-06 — trigger activated + activation {cost, exhaust}; Salvage Outpost proof; closes S2build
U4Combat lifecycle events: OnBeforeAttack / OnAfterAttack / OnBeforeDefend / OnAfterDefend / OnKill Add bus events attack_declared / attack_resolved / card_killed with attacker/target/rel filters in dcTriggerFilterMap; emit from the attack_card path. Cheap because the bus + combat both exist. ~½dshipped 2026-08-06 — events attack_declared/resolved + card_killed on the busbuild
U5“Choose one:” (ChoiceSelector over chain_abilities) New decision kind choice: options = named effect bundles; bot policy scores each option (reuse keep-value heuristics); logged like card decisions for replay. Unlocks modal cards. ~1dshipped 2026-08-06 — choose_one mechanic, options = effect bundles, pick loggedbuild
U6Target selectors: first N / random N / highest-stat / lowest-stat Selection specs + effect subject filters gain select: first|random|highest:<attr>|lowest:<attr> (+ count). Random draws from game RNG (determinism preserved). ~½dshipped 2026-08-06 — spec select: first|random|highest|lowestbuild
U7Event-card subject: effects reference the card that triggered them (AbilityTriggerer, LastPlayed/Destroyed…) Pattern-triggered effects gain subject event_card (the card in the event payload) so “when an enemy unit dies, deal 1 to it…” works. Engine keeps last_* registers only if a game needs LastPlayed-style spells. ~½dshipped 2026-08-06 — {source: event_card} targets the event's cardbuild
U8Mint cards at runtime: create / summon / transform from the design catalog Effects create_card {design, zone, owner} and transform_card {into}: instantiate a design (by name/tag filter) outside deck setup. Needed for token generators and Hearthstone-style discover. ~1dshipped 2026-08-06 — create_card mechanic (runtime tokens; not NFTs)build
U9Dice roll mechanic + roll conditions Effect roll_dice {sides} writing a game register readable by ƒx / conditions (rolled_value op). Trivial with seeded RNG; wait for a game that wants it. ~¼dshipped 2026-08-06 — roll_dice + rolled_value op, seededbuild
U10Equipment / attachment (card attached to card, bearer relation, EquippedCard target) Would need CardInstance.attached_to + follow-the-bearer + orphan cleanup. Real modelling work; defer until a probe game demands it. ~2dceiling for now
U11Secrets / traps: face-down reactive cards, max 1 fires per event, auto-discard after firing Mappable: let pattern-trigger subscriptions include a designated hidden zone + consume_on_fire flag + per-event cap. Defer with U10 until a game needs the genre. ~1dceiling for now
U12AI architecture: minimax over cloned state; heuristic weights (hvalue) carried in data assets; AI-only conditions; difficulty = noise injection Feeds the permanent bot-quality workstream: (a) our state is already clonable + deterministic → shallow lookahead bot is feasible; (b) add optional ai_value hints on effects/behaviours/actions so authored content tunes bots; (c) AI-only condition flag prevents self-harm targeting; (d) difficulty tiers via heuristic noise, not dumber rules. workstreamidea bank
U14Per-unit combat: attacker card, its own attack stat, mutual damage, exhaust (their AttackTarget pipeline)Planned as the first client of Verb Forge — action primitives (choose_target · transfer_value · exhaust_card) so both pooled and per-unit combat are AUTHORED, not coded. Includes un-hardcoding Permanent/Outpost and summoning sickness.~2-3dshipped 2026-08-08 (phases B-D) — attack_with_card preset: mutual attribute damage, exhaust, depletion deaths; authored, zero combat-specific codebuild
U13Board positioning: slots (x,y,side), one card per slot, distance/range conditions, movement Same ceiling as Clank!: positional boards are a different spatial model than zones. Only revisit if the product direction adds board games. ceiling

Reading order of value: U1 confirms the parked plan should build as designed (the user's “in case we find something related” hunch was right). U2 + U3 are the two big transferable mechanics. U4–U9 are cheap composability wins on systems that already exist. U10/U11/U13 wait for a game that needs them. U12 is a design pattern library for the bot workstream.