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.
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.
| Layer | Where | Count |
|---|---|---|
| Data model (ScriptableObject classes) | Scripts/Data/ — CardData, AbilityData, EffectData, ConditionData, FilterData, StatusData, TraitData, GameplayData… | 21 classes |
| Effect primitives | Scripts/Effects/ | 30 |
| Condition primitives + target filters | Scripts/Conditions/ | 23 + 4 |
| Rules engine | Scripts/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?) andconditions_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
valueint (anddurationfor 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:
| Asset | Reads as | Wiring |
|---|---|---|
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
| System | How it works |
|---|---|
| Turn flow | Only 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. |
| Resolution | FIFO 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 / auras | UpdateOngoing() 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). |
| Combat | Attack 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. |
| Statuses | 20-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. |
| Secrets | Face-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. |
| Board | Slots (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 memory | Engine 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
| TcgEngine | DeckCraft equivalent | Verdict |
|---|---|---|
| Traits (id labels) + Teams (fire/forest…) | tags[] (+ family axis) → all_tags runtime view | covered |
| TraitStat (trait + int value) | Free-form attributes + counters + engineEffectiveAttribute | covered |
| Ability anatomy (trigger/target/conditions/filters/effects/value) | Card effects: trigger preset or pattern · subject block · zones · ƒx params | covered structurally — gaps are the finding rows below |
| OnPlayOther / OnDeathOther / StartOfTurn triggers | Event bus pattern triggers (card_moved, turn_started…) | covered |
| Ongoing auras (clear-and-recompute push) | modifiers[] + active_while (compute-on-read) — same semantics, cheaper mechanism | covered |
| ResolveQueue + pause-for-selector | flow.stepQueue + decisions (bots answer inline; awaitingDecision scaffold) | covered |
| GameplayData config asset | game_meta + setup_spec | covered |
| Clonable state for AI simulation | Serializable 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-aware | U2 shipped |
| Activated abilities (cost + tap) | trigger activated + activation {cost, exhaust} + activate_ability verb | U3 shipped |
| Combat lifecycle triggers + keyword combat pipeline | Bus events attack_declared/resolved + card_killed | U4 shipped |
| ChoiceSelector (“choose one:”) | choose_one mechanic (logged decision) | U5 shipped |
| Filters first/random/highest/lowest | spec select: first|random|highest|lowest | U6 shipped |
| last_played / last_destroyed / ability_triggerer registers | {source: "event_card"} card targeting | U7 shipped |
| Create / Summon / Transform from the design catalog | create_card mechanic — runtime tokens | U8 shipped |
| Dice roll + rolled-value conditions | roll_dice + rolled_value op | U9 shipped |
| Equipment / attach (bearer relation) | No card-to-card attachment | U10 |
| Secrets / traps (face-down reactive) | Pattern triggers only fire for in-play cards | U11 |
| Board slots / positioning | Zones are unordered — same ceiling as Clank! | U13 |
| Minimax + hvalue-in-data + noise levels | Greedy bots; bot quality is the permanent workstream | U12 |
| Packs / rarities / variants / levels / sell ratio | Collection meta-game — out of simulator scope today | out of scope |
Findings — proposed wirings
| ID | Mechanic (TcgEngine) | Proposed DeckCraft wiring | Effort | Status | Decision |
|---|---|---|---|---|---|
| U1 | Reusable 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 kingdom | build |
| U2 | Statuses: 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). |
~2d | shipped 2026-08-06 — statuses grant behaviours; all gates/filters status-aware; apply/remove effects; turn-end expiry | build |
| U3 | Activated 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. |
~2d | shipped 2026-08-06 — trigger activated + activation {cost, exhaust}; Salvage Outpost proof; closes S2 | build |
| U4 | Combat 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. |
~½d | shipped 2026-08-06 — events attack_declared/resolved + card_killed on the bus | build |
| 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. |
~1d | shipped 2026-08-06 — choose_one mechanic, options = effect bundles, pick logged | build |
| U6 | Target 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). |
~½d | shipped 2026-08-06 — spec select: first|random|highest|lowest | build |
| U7 | Event-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. |
~½d | shipped 2026-08-06 — {source: event_card} targets the event's card | build |
| U8 | Mint 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. |
~1d | shipped 2026-08-06 — create_card mechanic (runtime tokens; not NFTs) | build |
| U9 | Dice 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. |
~¼d | shipped 2026-08-06 — roll_dice + rolled_value op, seeded | build |
| U10 | Equipment / 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. |
~2d | ceiling for now | |
| U11 | Secrets / 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. |
~1d | ceiling for now | |
| U12 | AI 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. |
workstream | idea bank | |
| U14 | Per-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-3d | shipped 2026-08-08 (phases B-D) — attack_with_card preset: mutual attribute damage, exhaust, depletion deaths; authored, zero combat-specific code | build |
| U13 | Board 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.