| Crates.io | cardinal-kernel |
| lib.rs | cardinal-kernel |
| version | 0.1.1 |
| created_at | 2026-01-25 13:49:57.052955+00 |
| updated_at | 2026-01-25 13:49:57.052955+00 |
| description | Headless, deterministic rules engine for turn-based, TCG-like games. |
| homepage | https://REPLACE-ME/homepage |
| repository | https://github.com/Big-Sky-Tech/Cardinal-Codex |
| max_upload_size | |
| id | 2068776 |
| size | 206,882 |
Welcome to Cardinal — the game engine library that powers TCG (trading card game) logic.
Imagine you're building a trading card game (like Magic: The Gathering or Yu-Gi-Oh). You need:
Cardinal handles all of this. You provide:
Cardinal gives you back:
Here's how any game that uses Cardinal works:
# 1. Create the engine
engine = CardinalEngine.new(rules_file="rules.toml", seed=12345)
# 2. Initialize the game
engine.start_game(player1_deck, player2_deck)
# 3. Game loop
while game_is_running:
# Show the current state to the player
display(engine.state)
# Get their action (e.g., "play card #5")
action = input("What do you do?")
# Apply the action; get back events
result = engine.apply_action(player_id, action)
# Show what happened
for event in result.events:
print_event(event)
That's it. Cardinal handles the complexity; you handle the UI.
Same game setup + same actions + same random seed = identical outcome.
Why does this matter?
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.
Cardinal has no idea what a screen is. It doesn't render anything. This is by design.
Why?
The role of Cardinal:
The role of the UI:
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.
There is one source of truth: the GameState struct inside Cardinal.
Why?
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:
turn.active_player)turn.phase)zones.hand[0])players[0].mana)All answers come from one place: the state.
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:
This ensures no one player can spam actions without giving the other a chance to respond.
In rules.toml, you define a card once:
[[cards]]
id = 1
name = "Goblin Scout"
type = "creature"
cost = "1R" # Cost: 1 generic mana + 1 red mana
description = "A small but feisty goblin."
power = 1
toughness = 1
[[cards.abilities]]
trigger = "etb" # "enters the battlefield"
effect = "damage" # type of effect
value = 1 # amount of damage
target = "opponent" # who gets hit
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:
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.
An action is what a player tells Cardinal to do. Examples:
// Play a card from your hand
PlayCard {
card_id: 1, // which card (Goblin Scout)
from_zone: Hand, // where it came from
}
// Pass priority to the opponent
PassPriority
// Activate a card ability
ActivateAbility {
card_id: 3, // which card
ability_index: 0, // which ability on that card
target: Opponent, // who it targets
mana_paid: "RR", // mana spent to activate
}
// In combat: declare which creatures attack
DeclareAttackers {
attackers: [1, 2, 5], // creature IDs
}
// In combat: declare which creatures block
DeclareBlockers {
blockers: [3], // creature ID
blocking: {3: 1}, // card 3 blocks card 1
}
// Concede (give up)
Concede
Cardinal validates every action:
If validation fails, an error is returned. Otherwise, the action is applied.
An event describes something that happened in the game. The UI reads events to know what to show.
Examples:
// A card was played
CardPlayed {
player: PlayerId(0),
card: CardId(1), // Goblin Scout
}
// A card moved from one zone to another
CardMoved {
card: CardId(1),
from_zone: Hand,
to_zone: Field,
}
// A creature entered the field (triggers abilities)
CreatureEntered {
card: CardId(1), // Goblin Scout
controller: PlayerId(0),
}
// An ability triggered
AbilityTriggered {
card: CardId(1),
ability: "etb_damage",
effect: "deal 1 damage",
}
// A life total changed
LifeChanged {
player: PlayerId(1),
old_life: 20,
new_life: 19, // Took 1 damage
}
// Stack item resolved
StackResolved {
item: "deal 1 damage to opponent",
result: "opponent lost 1 life",
}
// Priority passed
PriorityPassed {
from: PlayerId(0),
to: PlayerId(1),
}
// Phase advanced
PhaseChanged {
old_phase: "main",
new_phase: "end",
}
A typical UI might:
CardMovedLifeChangedAbilityTriggeredPriorityPassedCardinal doesn't care what the UI does. It just says "here's what happened."
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:
Triggers are how card abilities fire in response to events.
# Trigger on entry
[[cards.abilities]]
trigger = "etb" # "enters the battlefield"
# Trigger when played (cast)
[[cards.abilities]]
trigger = "on_play" # "when you cast this spell"
# Trigger at specific times
[[cards.abilities]]
trigger = "at_turn_start" # "at the start of your turn"
trigger = "at_turn_end" # "at the end of your turn"
# Trigger on events
[[cards.abilities]]
trigger = "when_creature_dies" # "when a creature dies"
trigger = "when_damage_dealt" # "when damage is dealt"
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.
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.
Cardinal has comprehensive tests:
19 Integration Tests covering:
Run tests:
cargo test
Each test is a small game scenario:
#[test]
fn test_card_ability_etb_trigger() {
// Setup: Create a game with a card that deals damage on ETB
let engine = create_test_game();
// Action: Play the card
engine.apply_action(player_0, Action::PlayCard { ... });
// Assertion: Opponent took damage
assert_eq!(engine.state.players[1].life, 19);
}
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)
| 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 |
[dependencies]
cardinal = { path = "../../crates/cardinal" }
Define your game:
[game]
name = "My Cool TCG"
[[phases]]
name = "start"
steps = ["untap", "upkeep", "draw"]
[[phases]]
name = "main"
[[phases]]
name = "combat"
[[phases]]
name = "end"
[[zones]]
name = "hand"
visible_to = "owner"
[[zones]]
name = "field"
visible_to = "all"
[[cards]]
id = 1
name = "Goblin Scout"
type = "creature"
cost = "1R"
# ... more cards
use cardinal::{GameEngine, Action, PlayerId};
let engine = GameEngine::new_from_file("rules.toml", seed)?;
engine.start_game(deck_0, deck_1)?;
loop {
let action = get_player_input();
let result = engine.apply_action(player_id, action)?;
for event in &result.events {
display_event(event);
}
}
cargo testcrates/cardinal/src/engine/core.rs is the entry pointCardinal is designed to be clear and extensible. Questions? The code is well-commented.