Cardinal Library: Complete Guide
Welcome to Cardinal — the game engine library that powers TCG (trading card game) logic.
What Does Cardinal Do?
Imagine you're building a trading card game (like Magic: The Gathering or Yu-Gi-Oh). You need:
- Game state management — tracking whose turn it is, what cards are in play, who has how much life
- Rule validation — checking if an action is legal before applying it
- Effect execution — running card abilities, applying damage, drawing cards
- Event tracking — recording what happened so the UI can show it to players
Cardinal handles all of this. You provide:
- A TOML file describing your game rules
- User input (which card to play, when to pass priority, etc.)
Cardinal gives you back:
- The updated game state
- A list of events describing what happened
Using Cardinal: The Basic Loop
Here's how any game that uses Cardinal works:
# 1. Create the engine
=
# 2. Initialize the game
# 3. Game loop
# Show the current state to the player
# Get their action (e.g., "play card #5")
=
# Apply the action; get back events
=
# Show what happened
That's it. Cardinal handles the complexity; you handle the UI.
Cardinal's Four Core Principles
1. Determinism
Same game setup + same actions + same random seed = identical outcome.
Why does this matter?
- Replays — You can record every move and replay the entire game perfectly
- Network fairness — Both players can run the engine on their machine and verify they got the same result
- Debugging — If a bug occurs, you can recreate it exactly by replaying
Example:
Seed: 42
Player 1 actions: [PlayCard(1), PassPriority, PlayCard(3), ...]
Player 2 actions: [PassPriority, PlayCard(2), ...]
Result: Player 1 wins with 3 life remaining
---
Run it again with the same seed and actions:
Player 1 still wins with exactly 3 life remaining. Every time.
2. Headless (No UI)
Cardinal has no idea what a screen is. It doesn't render anything. This is by design.
Why?
- Reusable — The same Cardinal engine can power a web game, desktop app, mobile game, Discord bot, or AI
- Testable — No UI framework to mock or deal with
- Clean — Game logic stays separate from presentation
The role of Cardinal:
- Take an action → validate it → apply it → emit events
The role of the UI:
- Take those events → render them → show animations/sounds
3. Actions In, Events Out
Cardinal's interface is simple and unidirectional:
┌─────────────────┐
│ Player/AI │ Sends an action: "I want to play card #5"
└────────┬────────┘
│
▼
┌─────────────────┐
│ Cardinal │ 1. Validates: "Is this legal right now?"
│ Engine │ 2. Applies: "OK, moving card to field"
│ │ 3. Triggers: "Does this trigger any abilities?"
│ │ 4. Emits: "Here's what happened..."
└────────┬────────┘
│
▼
┌─────────────────┐
│ Events │ [CardPlayed, CardMoved, AbilityTriggered, ...]
│ (what changed) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ UI/Client │ Reads events and updates the display
└─────────────────┘
This is one-way communication. The client doesn't directly query state; it listens to events. This keeps Cardinal decoupled from its consumers.
4. GameState is Authoritative
There is one source of truth: the GameState struct inside Cardinal.
Why?
- No conflicts — If two systems disagree about whose turn it is, GameState is the arbiter
- Consistency — Everything you need to know can be queried from the state
- Reproducibility — You can save and load the state at any point
GameState = {
turn: 1,
phase: "main",
step: "untap",
players: [Player { life: 20, }, Player { life: 18, }],
zones: {
hand[0]: [Card, Card, Card],
field[0]: [Card],
field[1]: [Card, Card],
library[0]: [...],
graveyard[0]: [...],
},
stack: [],
...
}
If you want to know "Can player 0 play a card right now?", you check:
- Is it their turn? (check
turn.active_player) - Is the game in a phase where playing is allowed? (check
turn.phase) - Do they have the card in hand? (check
zones.hand[0]) - Do they have enough mana? (check
players[0].mana)
All answers come from one place: the state.
Game Structure: Turns, Phases, Steps
A game follows a rigid sequence. This prevents chaos and ensures fairness.
Turn 1 (Player 0 is active)
├─ Start Phase
│ ├─ Untap Step: Untap all your permanents
│ ├─ Upkeep Step: Abilities that trigger "at the start of your turn" fire
│ └─ Draw Step: Draw 1 card
├─ Main Phase 1
│ ├─ Player 0 has priority (can play spells)
│ ├─ Player 1 can respond
│ └─ Continue until both pass consecutively
├─ Combat Phase
│ ├─ Player 0 declares which creatures attack
│ ├─ Player 1 declares blockers
│ └─ Damage is assigned
├─ Main Phase 2
│ ├─ Player 0 has priority again
│ └─ Can play more spells
└─ End Phase
├─ Abilities that trigger "at the end of the turn" fire
└─ Cleanup
Turn 2 (Player 1 is active)
└─ Same structure, but Player 1 is now the active player
Priority is how fairness is enforced:
- Player 0 has priority → can play spells
- Player 0 passes priority to Player 1
- Player 1 can respond with their own spells
- Player 1 passes back to Player 0
- Once both players pass consecutively → phase ends
This ensures no one player can spam actions without giving the other a chance to respond.
How Cards Work
Card Definitions (Static Data)
In rules.toml, you define a card once:
[[]]
= 1
= "Goblin Scout"
= "creature"
= "1R" # Cost: 1 generic mana + 1 red mana
= "A small but feisty goblin."
= 1
= 1
[[]]
= "etb" # "enters the battlefield"
= "damage" # type of effect
= 1 # amount of damage
= "opponent" # who gets hit
Card Execution (Data-Driven)
When a player plays this card:
Step 1: Player plays card #1
Step 2: Cardinal looks up card #1 in the registry → finds "Goblin Scout"
Step 3: Cardinal moves card from hand to field
Step 4: Cardinal checks: does this trigger any abilities?
→ Yes! "etb" trigger matches
Step 5: Cardinal creates a command: "Deal 1 damage to opponent"
Step 6: Command is added to the stack
Step 7: Stack resolves: 1 damage is dealt
Step 8: Events emitted: CardPlayed, CardMoved, AbilityTriggered, LifeChanged
Key insight: Cardinal never hardcodes card effects. All effects are defined in data (TOML). This means:
- You can create new cards without touching code
- You can customize the rule set per game
- Mods and plugins become possible
Zones: Where Cards Live
Every card in the game is in exactly one zone:
| Zone | What is it? | Public/Hidden | What can happen here? |
|---|---|---|---|
| Library | Your deck | Hidden | Cards are drawn from the top |
| Hand | Cards in your possession | Hidden (opponent can't see) | You play cards from here |
| Field | Cards in play | Public | Creatures attack, enchantments apply effects |
| Graveyard | Discard pile | Public | Cards that have been destroyed or discarded |
| Stack | Spells/abilities waiting to resolve | Public | Items wait in order, then resolve one by one |
| Exile | Cards removed from the game | Public | Typically can't be brought back |
Example: Playing a card
Before: Hand[0] = [Goblin Scout, Knight of Valor, ...]
Field[0] = []
Player plays Goblin Scout
After: Hand[0] = [Knight of Valor, ...]
Field[0] = [Goblin Scout]
The card moved from one zone to another. This triggers events and potentially card abilities.
Actions: What Players Can Do
An action is what a player tells Cardinal to do. Examples:
// Play a card from your hand
PlayCard
// Pass priority to the opponent
PassPriority
// Activate a card ability
ActivateAbility
// In combat: declare which creatures attack
DeclareAttackers
// In combat: declare which creatures block
DeclareBlockers
// Concede (give up)
Concede
Cardinal validates every action:
- Is it your turn?
- Is the game in a phase where this is allowed?
- Do you own the card?
- Do you have enough mana?
- Is the target legal?
If validation fails, an error is returned. Otherwise, the action is applied.
Events: What Happened
An event describes something that happened in the game. The UI reads events to know what to show.
Examples:
// A card was played
CardPlayed
// A card moved from one zone to another
CardMoved
// A creature entered the field (triggers abilities)
CreatureEntered
// An ability triggered
AbilityTriggered
// A life total changed
LifeChanged
// Stack item resolved
StackResolved
// Priority passed
PriorityPassed
// Phase advanced
PhaseChanged
A typical UI might:
- Animate card movement when it sees
CardMoved - Update the life counter when it sees
LifeChanged - Play a sound effect when it sees
AbilityTriggered - Show a notification when it sees
PriorityPassed
Cardinal doesn't care what the UI does. It just says "here's what happened."
Commands: The Intermediate Layer
When a card ability triggers, it doesn't directly change the game state. Instead, it emits a command that the engine validates and applies.
Why have this intermediate layer?
Card says: "Deal 1 damage"
↓
Returns Command::DealDamage { target: Opponent, amount: 1 }
↓
Engine validates: "Is the target valid? Do they exist?"
↓
Engine applies: Reduce opponent's life by 1
↓
Engine emits: Event::LifeChanged { old_life: 20, new_life: 19 }
Benefits:
- Safety — Validation happens before mutation
- Auditability — You can see what was requested and what was applied
- Extensibility — New command types can be added without rewriting the engine
- Scripting — Future mod/plugin systems can emit commands without direct state access
The Trigger System: Reactive Logic
Triggers are how card abilities fire in response to events.
Trigger Types
# Trigger on entry
[[]]
= "etb" # "enters the battlefield"
# Trigger when played (cast)
[[]]
= "on_play" # "when you cast this spell"
# Trigger at specific times
[[]]
= "at_turn_start" # "at the start of your turn"
= "at_turn_end" # "at the end of your turn"
# Trigger on events
[[]]
= "when_creature_dies" # "when a creature dies"
= "when_damage_dealt" # "when damage is dealt"
How Triggers Work
Event: CardPlayed { card: CardId(1) }
↓
Engine checks all cards:
"Does any card have an on_play trigger?"
↓
Card 1: "Inspiration" has on_play trigger
↓
Fire the trigger:
Create Command::DrawCards { count: 1 }
↓
Push to stack
↓
Stack resolves:
Player draws 1 card
↓
Emit: Event::CardDrawn { player, count: 1 }
This is data-driven. No hardcoded logic for each card. The engine is generic; cards define their behavior.
Integration Example: Playing a Card
Let's trace through what happens when you play a card:
Player says: "I want to play Goblin Scout (card #1) from my hand"
STEP 1: VALIDATION
└─ Is it your turn? YES
└─ Is the game in Main Phase? YES
└─ Do you own card #1? YES
└─ Is card #1 in your hand? YES
└─ Do you have 1 generic + 1 red mana? YES
└─ Decision: LEGAL ✓
STEP 2: EFFECT APPLICATION
└─ Remove card #1 from your hand
└─ Add card #1 to your field
└─ Subtract mana from your pool (1 generic, 1 red)
└─ Emit: Event::CardRemoved { card: 1, zone: Hand }
└─ Emit: Event::CardAdded { card: 1, zone: Field }
STEP 3: TRIGGER EVALUATION
└─ Event: CardMoved { from: Hand, to: Field }
└─ Check all cards: "Do any have an 'enters the field' trigger?"
└─ Goblin Scout has etb trigger: "deal 1 damage to opponent"
└─ Create Command::DealDamage { target: Opponent, amount: 1 }
└─ Add to stack
STEP 4: STACK RESOLUTION
└─ Stack has 1 item: DealDamage
└─ Resolve it: Subtract 1 from opponent's life (20 → 19)
└─ Emit: Event::LifeChanged { player: Opponent, old: 20, new: 19 }
└─ Remove from stack
STEP 5: RETURN EVENTS
└─ Return to player:
[
CardRemoved { card: 1, zone: Hand },
CardAdded { card: 1, zone: Field },
AbilityTriggered { card: 1, ability: etb_damage },
LifeChanged { player: Opponent, old: 20, new: 19 },
StackResolved { effect: DealDamage, amount: 1 },
]
UI reads events:
└─ CardRemoved/CardAdded → Animate card moving from hand to field
└─ AbilityTriggered → Show "Goblin Scout's ability triggered!"
└─ LifeChanged → Update opponent's life counter to 19
└─ StackResolved → Log "1 damage dealt to opponent"
That's one complete action. The loop repeats for each player action.
Testing
Cardinal has comprehensive tests:
19 Integration Tests covering:
- Game initialization (decks, hand drawing, first player)
- Turn progression (phase/step advancement)
- Action legality (validation rules)
- Card abilities (triggers, effects)
- Determinism (same seed → same outcome)
Run tests:
Each test is a small game scenario:
File Organization
crates/cardinal/src/
lib.rs # Main library exports
error.rs # Error types
ids.rs # NewType IDs (PlayerId, CardId, etc.)
state/
mod.rs # State module exports
gamestate.rs # The GameState struct (complete game snapshot)
zones.rs # Zone management (hand, field, graveyard, etc.)
rules/
mod.rs # Rules module exports
schema.rs # CardDef, CardAbility (data from TOML)
engine/
mod.rs # Engine module exports
core.rs # GameEngine struct and main apply_action()
reducer.rs # Apply effects to state
legality.rs # Validate actions
triggers.rs # Evaluate triggered abilities
cards.rs # CardRegistry (lookup cards by ID)
model/
mod.rs # Model module exports
action.rs # What players can do
event.rs # What happened
command.rs # Intermediate effects
choice.rs # Player input needed (pending choices)
display.rs # Terminal UI rendering (colors, formatting)
util/
rng.rs # Random number generator (seeded for determinism)
Key Concepts Summary
| Concept | What | Why |
|---|---|---|
| GameState | The complete game snapshot | Single source of truth |
| Action | What a player wants to do | Clear input interface |
| Event | What happened | Clear output interface |
| Command | Intermediate effect awaiting validation | Safety and auditability |
| Trigger | Card ability that fires in response to events | Data-driven card logic |
| Zone | Where a card is (hand, field, graveyard, etc.) | Organizes game structure |
| Priority | Whose turn to act | Ensures fairness |
| Phase/Step | What part of the turn are we in | Rigid structure prevents chaos |
| CardRegistry | HashMap of card definitions | O(1) card lookups |
| Determinism | Same inputs + seed = same outputs | Replays, fairness, debugging |
Using Cardinal in Your Project
1. Add to Cargo.toml
[]
= { = "../../crates/cardinal" }
2. Create a rules.toml
Define your game:
[]
= "My Cool TCG"
[[]]
= "start"
= ["untap", "upkeep", "draw"]
[[]]
= "main"
[[]]
= "combat"
[[]]
= "end"
[[]]
= "hand"
= "owner"
[[]]
= "field"
= "all"
[[]]
= 1
= "Goblin Scout"
= "creature"
= "1R"
# ... more cards
3. Initialize and Run
use ;
let engine = new_from_file?;
engine.start_game?;
loop
Next Steps
- Read ARCHITECTURE.md for a deeper dive into design
- Check ../cardinal-cli/ for a working example
- Run tests:
cargo test - Explore the code:
crates/cardinal/src/engine/core.rsis the entry point
Cardinal is designed to be clear and extensible. Questions? The code is well-commented.