itch.io is community of indie game creators and players

Devlogs

Engine Overview

If you have ever tried to build a card game, you already know the shape of the problem. The community has excellent frameworks for the presentation layer — dragging cards, fanning a hand, animating a board, laying out piles. What none of them give you is the part that actually is the game: turns, mana, drawing, attacking, blocking, resolving damage, and an opponent that makes decisions. That logic is where every card project quietly sinks its weeks.

Card Combat Engine is exactly that missing piece for Godot 4 It is the combat brain meant to sit underneath a card UI, not replace one. No rendering, no assets, no assumptions about your game baked in. This devlog is a tour of what it does and, more usefully, how to squeeze the most out of it.

The one idea everything else hangs on: agnosticism by injection

The engine knows nothing about your GDD. It has never heard of rarities, factions, "weapons," "terrain," or any specific ability. That is deliberate, and it is the single most important thing to understand before you write a line of integration code.

Anything game-specific reaches the engine through one of two doors:

  • Opaque containers. CardData.metadata is a plain Dictionary; HiddenCardStats.declared_abilities is a plain Array. You stuff whatever your game needs in there. The engine carries it, serializes it, and never reads it.
  • Callable injection points. Want custom ability semantics, a custom damage formula, custom mana costs, fatigue, auras? You hand the engine a Callable and it calls you at the right moment.

The practical upshot: you extend the engine without ever forking it. When the Asset Library ships an update, you pull it and nothing breaks, because your game lives entirely in your handlers, not in patches to the engine's source.

How to get the most out of it

1. Put your card taxonomy in metadata, never in new fields

The biggest mistake new users make is reaching for "let me just add a faction field to CardData." Don't. CardData has exactly one behavioral switch, play_kind (UNIT, EFFECT, PERSISTENT), and that is an engine dispatch — does this thing fight, resolve a spell, or sit on the board as an enchantment? Your "Arma / Terreno / Legendary" taxonomy is game vocabulary and belongs in metadata. Keep that line clean and the engine stays upgradeable forever.

2. Drive abilities through ability_fn and the trigger set

The heart of customization is ability_fn: a single Callable with the signature (inst, trigger, context). The engine fires it at every meaningful moment — ON_PLAY, ON_DEATH, ON_ATTACK, ON_DAMAGE_DEALT, ON_HEAL, ON_DRAW, ON_CAST, and more — handing you the instance and a context dictionary of primitives. Your handler reads the card's keywords from metadata and decides what happens. The engine never knows what "lifesteal" means; you do.

That one hook covers battlecries (ON_PLAY carries the chosen target), deathrattles (ON_DEATH), overkill/trample (ON_DAMAGE_DEALT hands you lethal and excess), and spell synergy (ON_CAST fires once after a spell resolves). Start here before you reach for anything fancier.

In practice a handler is just a match over the trigger that reads your own keywords out of metadata. A "lifesteal" creature, for instance, is nothing the engine understands — it is six lines in your handler:

func my_ability_handler(inst, trigger, context):
    var keywords = inst.card_data.metadata.get("keywords", [])
    match trigger:
        CardInstance.Trigger.ON_DAMAGE_DEALT:
            if "lifesteal" in keywords:
                # heal my hero for the damage this creature just dealt
                session.heal_hero(inst.owner_id, context["amount"])

Notice what is not there: no engine change, no subclass, no enum entry. The keyword is an opaque string the engine carries and you interpret. Add "lifesteal" to a card's metadata["keywords"] and it now drains life. That is the whole extension model, and it scales from one keyword to a hundred without the engine growing a single branch.

3. Don't reinvent keywords — the AbilityLibrary is a free starting kit

Shipping with the engine is an opt-in package, AbilityLibrary, that already implements a catalog of keywords on top of the public surface: CHARGE, IMMUNITY, LIFESTEAL, THORNS, STEALTH, WINDFURY, FREEZE, BATTLECRY, OVERKILL, SPELLBURST, TAUNT, ARMOR, SPELLPOWER and LORD. You call wire_all() and your cards get those behaviors by simply listing the keyword in metadata["keywords"].

Two reasons this matters even if you intend to write your own: it is a worked example of how to build abilities purely through ability_fn and attack_restriction_fn without touching the engine, and it shows the tricky parts done right — weak references back to the session so nothing leaks, compose_restrictions() so TAUNT and STEALTH coexist in the single restriction slot, and recurring effects like SPELLBURST. Read it, copy the patterns, then go your own way.

4. Lean on determinism — it is the engine's superpower

Everything random — shuffles, the reference AI — is seeded. A combat with a fixed seed plays out bit-for-bit identically, every time, on every machine. The whole state, including the AI's RNG, round-trips through serialize() / deserialize().

This unlocks three things most card engines make you build yourself:

  • Replays. The event_log is a stream of serializable events (it stores card_id, never live instances). Save it, replay it, build a spectator mode on it.
  • Server-authoritative netcode. The mirror of the event log is the command_log: every driver action a client requests goes through apply_command(), which validates side and indices and rejects illegal input as a no-op. One validation point, fully serializable — that is the spine of authoritative multiplayer.
  • Headless balancing. Run thousands of auto_resolve() matches with no UI to tune your numbers. For mass runs, flip config.record_events = false to skip log allocation entirely — the result is identical, measured ~30% faster on creature-heavy workloads.

5. Use the AI contract as a real opponent, not a placeholder

AI is just a driver for whichever side you assign it via ais[side]. The contract (CombatAI) is five methods. DummyAI is the seeded reference; HeuristicAI is a stronger, deterministic greedy opponent that plays a mana curve, trades by value, and blocks threats. Because a side is "just a driver," the same method surface drives a human (your UI calls the methods), an AI, or a network client — which is precisely why the engine is PvP-ready out of the box.

6. Reach for the other hooks only when you need them

Beyond ability_fn, the engine exposes targeted Callables: damage_fn (your damage formula), cost_fn (effective mana cost), incoming_damage_fn (per-instance armor/prevention/redirection, covering both combat and spell damage), spell_power_fn, aura_fn (recompute continuous modifiers when the board changes), exhaust_fn (fatigue), and discard_fn. Every one of them is empty by default and additive: leave it unset and you get pure-engine behavior. Add them one at a time as your design demands.

7. Go beyond 1v1 when you want to

State is indexed per side in N-sized arrays, so the same engine runs 1v1, 2v2, and N-sided free-for-all via setup_sides(sides, teams). The turn order interleaves teams so allies don't play back to back, dead sides are skipped, and spell/attack targeting resolves through team topology rather than a hardcoded "the other player." setup() is just the 1v1 convenience wrapper over it.

What a typical integration looks like

To make the shape concrete, here is the arc of a real integration, start to finish. First you author your cards as your own resources and dump everything game-specific into metadata — cost and base stats the engine reads directly, but rarity, art keys, flavor text and your keyword list all ride along opaquely. Then you write one ability_fn that switches on the trigger and your keywords, exactly like the snippet above; for anything in the standard catalog you can let AbilityLibrary.wire_all() handle it and only write handlers for your bespoke cards. You set a CombatConfig for your balance numbers — starting mana, mana cap, hand size, board size — and assign it before setup(). You point the engine at your hero (a Combatant subclass) and your decks, call setup() then start(), and from there your UI simply calls play_card, declare_attacker, declare_blocker and the phase-ending methods in response to player input, while listening to the engine's signals to animate what happened. Swap your UI for a DummyAI on both sides and the exact same code base becomes a headless balancing harness. Nothing about that flow required editing the engine, and that is the entire design goal.

What it is not

It is not a UI toolkit, and it does not try to be. Pair it with a presentation framework for hands and drag-and-drop; the engine sits underneath and owns the rules. It also won't encode your balance numbers for you — caps on board size, hand size and permanent buffs all live in CombatConfig, defaulting to "unlimited," because the engine refuses to assume your game's math.

Getting it

Card Combat Engine is on the Godot Asset Library (search Card Combat Engine) and the source is public on GitHub under the GNU AGPL v3.0 — free for open-source projects. If you need it in a closed-source or server-side product without the AGPL's copyleft, a commercial license is available on itch.io.

Engine: github.com/JavierIslas/Card-Combat-System
Commercial license: dimcairion.itch.io/card-combat-engine
See it in a real game: Espíritus Ancestrales

Files

  • card_combat_engine_commercial.zip 131 kB
    4 days ago
Download Card Combat Engine Commercial License (Godot 4)
Leave a comment