Trading Card Game

Card Transitional
authsession-authecs

Transitional example: This example uses an older Cougr pattern and is preserved for compatibility reference. For the current recommended approach, see snake.

A two-player on-chain trading card game built with the Cougr ECS framework on Stellar Soroban. This example demonstrates atomic multi-action turns via BatchBuilder and match-scoped sessions via SessionBuilder.

Why Atomic Turns Matter

In a trading card game a single turn consists of multiple sequential actions: play a creature, cast a spell, declare attacks. If the player's actions were submitted as separate transactions:

  • A partially-executed turn could leave the game in an invalid intermediate state (e.g., a card removed from hand but never placed on the field).
  • The opponent could observe and exploit that intermediate state.
  • A wallet approval prompt between every action would create terrible UX.

BatchBuilder solves all three problems. The entire turn - every PlayCreature, CastSpell, and DeclareAttack - is composed into a single batch. Because a Soroban contract invocation is atomic by design, any panic inside submit_turn reverts every storage write made during that call. Either the whole turn succeeds, or nothing changes.

How it works

submit_turn(player, actions)
  │
  ├─ Guard checks (turn ownership, session validity)
  │
  ├─ DrawSystem   ← draw one card at turn start
  ├─ ManaSystem   ← increment max-mana, refill current mana
  │
  ├─ BatchBuilder ← assemble one GameAction per player action
  │                 (proves intent; acts as the turn manifest)
  │
  ├─ For each action in the batch:
  │     PlayCreature  → PlayCardSystem   (validate mana, move card to field)
  │     CastSpell     → CastSpellSystem  (validate mana, deal damage)
  │     DeclareAttack → CombatSystem     (resolve damage, remove dead creatures)
  │                     ↑ any PANIC here reverts the entire call
  │
  ├─ WinConditionSystem ← check health totals
  └─ Advance turn

If, say, the second action in the batch runs out of mana, the contract panics, and all state changes (including the first action that succeeded) are discarded - the player's hand, field, stats, and mana are exactly as they were before the call.

Session Keys

Each player calls start_session at the beginning of the match. This builds a SessionScope (via SessionBuilder) that:

  • Allows only the three game actions: play, spell, attack.
  • Caps the session at 200 operations.
  • Expires after a 2-hour TTL (7 200 ledger seconds).

submit_turn validates the session before executing any actions. If the session has expired, the entire call is rejected - no stale turns can sneak in after a match is effectively abandoned.

Game Flow

1. MATCH SETUP
   new_match(player_a, player_b, deck_a, deck_b)
     → Both players draw STARTING_HAND_SIZE cards
     → stats: health=20, mana=1/1

   start_session(player_a)   → returns expiry timestamp
   start_session(player_b)   → returns expiry timestamp

2. EACH TURN  (atomic batch)
   submit_turn(active_player, [
     PlayCreature(card_id),
     CastSpell(card_id),
     DeclareAttack(attacker_idx, target_idx),
     ...
   ])
   → DrawSystem:   draw 1 card
   → ManaSystem:   max_mana++, mana refill
   → BatchBuilder: compose action manifest
   → Execute actions atomically
   → WinCondition: check health == 0
   → Advance turn

3. GAME END
   status == StatusAWins | StatusBWins | Conceded

Card Library

IDKindCostPowerToughnessNotes
1Creature112Cheap blocker
2Creature222Vanilla
3Creature213Sturdy blocker
4Creature332Aggressive
5Creature324Resilient
6Creature444Mid-range
7Creature555Threat
8Creature666Late-game bomb
9Spell23-3 direct damage
10Spell45-5 direct damage

ECS Components

ComponentFieldsPurpose
PlayerHandcards: Vec<CardId>Cards currently held
PlayerFieldcreatures: Vec<CreatureState>Creatures on the battlefield
PlayerStatshealth, mana, max_manaHealth and resource pool
MatchStateturn, active_player, phase, statusMatch phase tracking

ECS Systems

SystemTriggerResponsibility
DrawSystemTurn startDraw one card from deck into hand
ManaSystemTurn startIncrement max mana (cap 10), refill mana
PlayCardSystemPlayCreatureValidate mana, move card to field
CastSpellSystemCastSpellValidate mana, deal damage to opponent
CombatSystemDeclareAttackResolve creature-vs-creature or face damage
WinConditionSystemEnd of turnDetect health == 0, set match status

Contract API

#![allow(unused)]
fn main() {
// Setup
fn new_match(env, player_a, player_b, deck_a, deck_b)
fn start_session(env, player) -> u64  // returns session expiry

// Gameplay
fn submit_turn(env, player, actions: Vec<Action>) -> TurnResult
fn concede(env, player)

// Queries
fn get_state(env) -> MatchState
fn get_hand(env, player) -> Vec<Card>
fn get_field(env) -> FieldState
fn get_stats(env, player) -> PlayerStats
}

Building and Testing

# Build
cargo build

# Run tests
cargo test

# Build WASM contract
stellar contract build

Dependencies

[dependencies]
soroban-sdk = "25.1.0"
cougr-core  = { branch = "main", git = "https://github.com/salazarsebas/Cougr.git" }

Resources