mantimon-tcg/docs/ARCHITECTURE.md
Cal Corum f473f94bce Initial project setup: documentation and structure
- Add PROJECT_PLAN.md with 7-phase development roadmap
- Add CLAUDE.md with AI agent coding guidelines
- Add docs/ARCHITECTURE.md with technical deep-dive
- Add docs/GAME_RULES.md template for home rule definitions
- Create directory structure for frontend, backend, shared
2026-01-23 23:41:34 -06:00

28 KiB

Mantimon TCG - Technical Architecture

This document provides a detailed technical overview of the Mantimon TCG architecture.

Table of Contents

  1. System Overview
  2. Frontend Architecture
  3. Backend Architecture
  4. Database Schema
  5. Real-Time Communication
  6. Game Engine
  7. AI System
  8. Security Considerations

System Overview

+------------------+     +------------------+     +------------------+
|     Browser      |     |     Browser      |     |     Browser      |
|  (Vue + Phaser)  |     |  (Vue + Phaser)  |     |  (Vue + Phaser)  |
+--------+---------+     +--------+---------+     +--------+---------+
         |                        |                        |
         +------------------------+------------------------+
                                  |
                         WebSocket + HTTP
                                  |
                    +-------------+-------------+
                    |                           |
           +--------v--------+         +--------v--------+
           |    FastAPI      |         |    FastAPI      |
           |   Instance 1    |         |   Instance 2    |
           +--------+--------+         +--------+--------+
                    |                           |
                    +-------------+-------------+
                                  |
              +-------------------+-------------------+
              |                   |                   |
      +-------v-------+   +-------v-------+   +------v------+
      |  PostgreSQL   |   |     Redis     |   |   S3/CDN    |
      |  (persistent) |   |   (realtime)  |   | (card art)  |
      +---------------+   +---------------+   +-------------+

Frontend Architecture

Technology Stack

  • Vue 3 with Composition API and <script setup> syntax
  • Phaser 3 for canvas-based game rendering
  • TypeScript for type safety
  • Pinia for state management
  • Tailwind CSS for styling
  • Vite for development and building

Component Hierarchy

App.vue
├── layouts/
│   ├── DefaultLayout.vue      # Nav, footer for non-game pages
│   └── GameLayout.vue         # Minimal chrome for gameplay
├── pages/
│   ├── index.vue              # Landing/home
│   ├── login.vue              # Authentication
│   ├── collection.vue         # Card collection browser
│   ├── decks/
│   │   ├── index.vue          # Deck list
│   │   └── [id].vue           # Deck builder/editor
│   ├── play/
│   │   ├── index.vue          # Play menu (AI, PvP, Puzzle)
│   │   ├── match/[id].vue     # Active game (mounts Phaser)
│   │   └── puzzle/[id].vue    # Puzzle mode
│   ├── packs.vue              # Pack opening (mounts Phaser)
│   └── profile.vue            # User stats and settings
└── components/
    ├── cards/
    │   ├── CardDisplay.vue    # Single card render (DOM)
    │   ├── CardGrid.vue       # Grid of cards
    │   └── CardDetail.vue     # Full card modal
    ├── decks/
    │   ├── DeckList.vue       # List of user's decks
    │   └── DeckBuilder.vue    # Deck editing UI
    ├── game/
    │   └── PhaserContainer.vue # Mounts Phaser canvas
    └── common/
        ├── Modal.vue
        ├── Button.vue
        └── Loading.vue

Vue + Phaser Integration

Phaser is mounted within a Vue component and communicates via an event bridge:

// components/game/PhaserContainer.vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import Phaser from 'phaser'
import { MatchScene } from '@/game/scenes/MatchScene'
import { useGameStore } from '@/stores/game'

const props = defineProps<{
  gameId: string
}>()

const gameContainer = ref<HTMLDivElement>()
const phaserGame = ref<Phaser.Game>()
const gameStore = useGameStore()

onMounted(() => {
  phaserGame.value = new Phaser.Game({
    type: Phaser.AUTO,
    parent: gameContainer.value,
    width: 1280,
    height: 720,
    scene: [MatchScene],
    backgroundColor: '#1a1a2e',
  })
  
  // Pass initial state to Phaser
  phaserGame.value.registry.set('gameId', props.gameId)
  
  // Bridge: Vue -> Phaser
  gameStore.$onAction(({ name, args }) => {
    if (name === 'setGameState') {
      phaserGame.value?.events.emit('state:update', args[0])
    }
  })
  
  // Bridge: Phaser -> Vue
  phaserGame.value.events.on('player:action', (action) => {
    gameStore.sendAction(action)
  })
})

onUnmounted(() => {
  phaserGame.value?.destroy(true)
})
</script>

<template>
  <div ref="gameContainer" class="w-full h-full" />
</template>

Phaser Scene Structure

game/
├── scenes/
│   ├── MatchScene.ts          # Main gameplay
│   ├── PackOpeningScene.ts    # Animated pack reveals
│   └── PuzzleScene.ts         # Puzzle mode
├── objects/
│   ├── Card.ts                # Card game object
│   ├── Hand.ts                # Hand container
│   ├── Battlefield.ts         # Active/bench zones
│   ├── Deck.ts                # Deck pile with animations
│   └── DamageCounter.ts       # Damage display
├── animations/
│   ├── cardAnimations.ts      # Draw, play, flip
│   ├── attackAnimations.ts    # Attack effects
│   └── shuffleAnimations.ts   # Deck shuffle
└── utils/
    └── tweenHelpers.ts        # Common tween patterns

State Management (Pinia)

// stores/game.ts
export const useGameStore = defineStore('game', () => {
  // State
  const gameState = ref<GameState | null>(null)
  const pendingAction = ref<Action | null>(null)
  const socket = ref<Socket | null>(null)
  
  // Getters
  const myHand = computed(() => gameState.value?.myHand ?? [])
  const isMyTurn = computed(() => 
    gameState.value?.currentPlayer === gameState.value?.myPlayerId
  )
  const canPlayCard = computed(() => 
    isMyTurn.value && gameState.value?.phase === 'main'
  )
  
  // Actions
  async function connectToGame(gameId: string) {
    socket.value = io('/game', { query: { gameId } })
    
    socket.value.on('game:state', (state) => {
      gameState.value = state
    })
    
    socket.value.on('game:error', (error) => {
      // Handle error
    })
  }
  
  function sendAction(action: Action) {
    socket.value?.emit('game:action', action)
  }
  
  return {
    gameState,
    myHand,
    isMyTurn,
    canPlayCard,
    connectToGame,
    sendAction,
  }
})

Backend Architecture

Technology Stack

  • FastAPI for REST API and WebSocket handling
  • Python 3.11+ with async/await
  • SQLAlchemy 2.0 (async) for ORM
  • PostgreSQL for persistent storage
  • Redis for caching and real-time state
  • python-socketio for WebSocket server
  • Pydantic v2 for validation

Module Structure

backend/
├── app/
│   ├── main.py                 # FastAPI app, Socket.io setup
│   ├── config.py               # Settings (pydantic-settings)
│   ├── dependencies.py         # Dependency injection
│   │
│   ├── api/                    # REST endpoints
│   │   ├── auth.py             # Login, register, OAuth
│   │   ├── cards.py            # Card definitions
│   │   ├── collection.py       # User collection CRUD
│   │   ├── decks.py            # Deck CRUD
│   │   ├── packs.py            # Pack opening
│   │   ├── matchmaking.py      # Queue management
│   │   └── puzzles.py          # Puzzle definitions
│   │
│   ├── websocket/              # Real-time handlers
│   │   ├── handlers.py         # Socket.io event handlers
│   │   ├── connection_manager.py
│   │   └── events.py           # Event type definitions
│   │
│   ├── core/                   # Game engine
│   │   ├── game_engine.py      # Main orchestrator
│   │   ├── turn_manager.py     # Turn/phase state machine
│   │   ├── card_effects.py     # Effect resolution
│   │   ├── rules_validator.py  # Action legality
│   │   ├── win_conditions.py   # Win/loss detection
│   │   ├── state_manager.py    # In-memory game state
│   │   └── ai/                 # AI opponents
│   │       ├── base.py         # AI interface
│   │       ├── easy.py         # Random legal moves
│   │       ├── medium.py       # Heuristic-based
│   │       └── hard.py         # MCTS/minimax
│   │
│   ├── models/                 # Data models
│   │   ├── database.py         # SQLAlchemy models
│   │   └── schemas.py          # Pydantic schemas
│   │
│   ├── services/               # Business logic
│   │   ├── auth_service.py
│   │   ├── card_service.py
│   │   ├── collection_service.py
│   │   ├── deck_service.py
│   │   ├── pack_service.py
│   │   ├── game_service.py
│   │   └── puzzle_service.py
│   │
│   └── database/               # DB utilities
│       ├── session.py          # Async session factory
│       └── migrations/         # Alembic migrations
│
├── tests/
│   ├── test_game_engine.py
│   ├── test_card_effects.py
│   ├── test_ai.py
│   └── conftest.py
│
├── pyproject.toml
└── alembic.ini

Request Flow

HTTP Request
     │
     v
FastAPI Router ─────────────────────────────┐
     │                                      │
     v                                      v
Dependency Injection              WebSocket Connection
(auth, db session)                      │
     │                                  v
     v                           Socket.io Handler
Service Layer                          │
     │                                 v
     v                           Game Engine
Repository/ORM                   (in-memory state)
     │                                 │
     v                                 v
PostgreSQL                          Redis
                                (state cache)

Database Schema

Entity Relationship Diagram

+---------------+       +------------------+       +---------------+
|    users      |       |   collections    |       |    cards      |
+---------------+       +------------------+       +---------------+
| id (PK)       |<──────| user_id (FK)     |       | id (PK)       |
| username      |       | card_id (FK)     |───────| name          |
| email         |       | quantity         |       | set_id        |
| password_hash |       +------------------+       | hp            |
| currency      |                                  | type          |
| created_at    |                                  | attacks (JSON)|
+---------------+                                  | abilities     |
       │                                           | rarity        |
       │                                           +---------------+
       │
       │         +------------------+
       │         |      decks       |
       │         +------------------+
       └────────>| id (PK)          |
                 | user_id (FK)     |
                 | name             |
                 | cards (JSON)     |
                 | is_valid         |
                 | created_at       |
                 +------------------+

+------------------+       +------------------+
|     matches      |       |  match_players   |
+------------------+       +------------------+
| id (PK)          |<──────| match_id (FK)    |
| status           |       | user_id (FK)     |
| winner_id (FK)   |       | deck_id (FK)     |
| started_at       |       | result           |
| ended_at         |       +------------------+
| game_log (JSON)  |
+------------------+

+------------------+       +------------------+
|      packs       |       |   pack_contents  |
+------------------+       +------------------+
| id (PK)          |<──────| pack_id (FK)     |
| name             |       | card_id (FK)     |
| cost             |       | rarity           |
| set_id           |       | weight           |
+------------------+       +------------------+

+------------------+
|     puzzles      |
+------------------+
| id (PK)          |
| name             |
| description      |
| difficulty       |
| initial_state    |
| solution         |
| hints (JSON)     |
+------------------+

Key Models

# models/database.py
from sqlalchemy import Column, Integer, String, JSON, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from app.database.session import Base

class User(Base):
    __tablename__ = "users"
    
    id = Column(Integer, primary_key=True)
    username = Column(String(50), unique=True, nullable=False)
    email = Column(String(255), unique=True, nullable=False)
    password_hash = Column(String(255), nullable=False)
    currency = Column(Integer, default=0)
    created_at = Column(DateTime, server_default=func.now())
    
    collections = relationship("Collection", back_populates="user")
    decks = relationship("Deck", back_populates="user")


class Card(Base):
    __tablename__ = "cards"
    
    id = Column(String(50), primary_key=True)  # e.g., "pikachu_base_001"
    name = Column(String(100), nullable=False)
    set_id = Column(String(50), nullable=False)
    card_type = Column(String(20), nullable=False)  # pokemon, trainer, energy
    hp = Column(Integer, nullable=True)  # Only for Pokemon
    pokemon_type = Column(String(20), nullable=True)  # fire, water, etc.
    attacks = Column(JSON, default=[])
    abilities = Column(JSON, default=[])
    weakness = Column(JSON, nullable=True)
    resistance = Column(JSON, nullable=True)
    retreat_cost = Column(Integer, default=0)
    rarity = Column(String(20), nullable=False)
    image_url = Column(String(500), nullable=True)


class Deck(Base):
    __tablename__ = "decks"
    
    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    name = Column(String(100), nullable=False)
    cards = Column(JSON, nullable=False)  # [{"card_id": "...", "quantity": 2}, ...]
    is_valid = Column(Boolean, default=False)
    created_at = Column(DateTime, server_default=func.now())
    updated_at = Column(DateTime, onupdate=func.now())
    
    user = relationship("User", back_populates="decks")

Real-Time Communication

Socket.io Events

Client -> Server

Event Payload Description
game:action { type, ...params } Player performs action
game:resign {} Player resigns match
matchmaking:join { deckId } Join matchmaking queue
matchmaking:leave {} Leave queue

Server -> Client

Event Payload Description
game:state VisibleGameState Full state update
game:action_result { success, error? } Action confirmation
game:ended { winner, reason } Match concluded
matchmaking:found { gameId, opponent } Match found

Action Types

class ActionType(str, Enum):
    PLAY_POKEMON = "play_pokemon"      # Play basic Pokemon to bench
    EVOLVE = "evolve"                   # Evolve a Pokemon
    ATTACH_ENERGY = "attach_energy"     # Attach energy card
    PLAY_TRAINER = "play_trainer"       # Play trainer card
    USE_ABILITY = "use_ability"         # Activate Pokemon ability
    ATTACK = "attack"                   # Declare attack
    RETREAT = "retreat"                 # Retreat active Pokemon
    PASS = "pass"                       # End turn / pass priority

State Synchronization

# websocket/handlers.py
@sio.on('game:action')
async def handle_action(sid, data: dict):
    session = await get_session(sid)
    game_id = session['game_id']
    player_id = session['player_id']
    
    game = await state_manager.get_game(game_id)
    
    # Validate
    result = await game_engine.validate_action(game, player_id, data)
    if not result.valid:
        await sio.emit('game:action_result', {
            'success': False,
            'error': result.reason
        }, to=sid)
        return
    
    # Execute
    new_state = await game_engine.execute_action(game, player_id, data)
    await state_manager.save_game(game_id, new_state)
    
    # Broadcast filtered state to each player
    for pid, psid in game.player_sockets.items():
        visible_state = game_engine.get_visible_state(new_state, pid)
        await sio.emit('game:state', visible_state, to=psid)
    
    # Check win conditions
    winner = game_engine.check_win_condition(new_state)
    if winner:
        await handle_game_end(game_id, winner)

Game Engine

Turn Structure

GAME START
    │
    v
┌─────────────────────────────────┐
│  SETUP PHASE                    │
│  - Draw 7 cards each            │
│  - Place active + bench         │
│  - Set aside 6 prize cards      │
│  - Flip coin for first turn     │
└─────────────────────────────────┘
    │
    v
┌─────────────────────────────────┐
│  TURN START                     │◄──────────────────┐
│  - Draw 1 card                  │                   │
└─────────────────────────────────┘                   │
    │                                                 │
    v                                                 │
┌─────────────────────────────────┐                   │
│  MAIN PHASE (any order)         │                   │
│  - Attach 1 energy (once)       │                   │
│  - Play basic Pokemon to bench  │                   │
│  - Evolve Pokemon               │                   │
│  - Play trainer cards           │                   │
│  - Use abilities                │                   │
│  - Retreat active (once)        │                   │
└─────────────────────────────────┘                   │
    │                                                 │
    v                                                 │
┌─────────────────────────────────┐                   │
│  ATTACK PHASE (optional)        │                   │
│  - Declare attack               │                   │
│  - Resolve damage + effects     │                   │
│  - Check knockouts              │                   │
│  - Take prize cards             │                   │
└─────────────────────────────────┘                   │
    │                                                 │
    v                                                 │
┌─────────────────────────────────┐                   │
│  END PHASE                      │                   │
│  - Apply end-of-turn effects    │                   │
│  - Switch to opponent           │───────────────────┘
└─────────────────────────────────┘
    │
    v
┌─────────────────────────────────┐
│  WIN CONDITION CHECK            │
│  - All prizes taken?            │
│  - Opponent has no Pokemon?     │
│  - Opponent can't draw?         │
│  - Custom home rules?           │
└─────────────────────────────────┘

Card Effect System

Effects are data-driven with pluggable handlers:

# core/card_effects.py
from typing import Protocol, Callable, Dict, Any

class EffectContext:
    game: GameState
    source_card: Card
    source_player: str
    target_card: Optional[Card]
    target_player: Optional[str]
    params: Dict[str, Any]

EffectHandler = Callable[[EffectContext], Awaitable[None]]

EFFECT_REGISTRY: Dict[str, EffectHandler] = {}

def effect_handler(name: str):
    """Decorator to register effect handlers."""
    def decorator(func: EffectHandler):
        EFFECT_REGISTRY[name] = func
        return func
    return decorator

# Example effects
@effect_handler("deal_damage")
async def deal_damage(ctx: EffectContext):
    amount = ctx.params["amount"]
    ctx.target_card.damage += amount

@effect_handler("heal")
async def heal(ctx: EffectContext):
    amount = ctx.params["amount"]
    ctx.target_card.damage = max(0, ctx.target_card.damage - amount)

@effect_handler("apply_status")
async def apply_status(ctx: EffectContext):
    status = ctx.params["status"]  # paralyzed, poisoned, asleep, etc.
    ctx.target_card.status_conditions.append(status)

@effect_handler("draw_cards")
async def draw_cards(ctx: EffectContext):
    count = ctx.params["count"]
    player = ctx.game.players[ctx.source_player]
    for _ in range(count):
        if player.deck:
            player.hand.append(player.deck.pop())

@effect_handler("coin_flip")
async def coin_flip(ctx: EffectContext):
    """Flip coin, execute sub-effect on heads."""
    result = random.choice(["heads", "tails"])
    if result == "heads" and "on_heads" in ctx.params:
        sub_effect = ctx.params["on_heads"]
        await resolve_effect(ctx.game, sub_effect, ctx)

AI System

Architecture

# core/ai/base.py
from abc import ABC, abstractmethod

class AIPlayer(ABC):
    """Base class for AI opponents."""
    
    @abstractmethod
    async def choose_action(self, game_state: GameState) -> Action:
        """Given the current game state, return the best action."""
        pass
    
    def get_legal_actions(self, game_state: GameState) -> List[Action]:
        """Returns all legal actions for the AI player."""
        actions = []
        player = game_state.players[self.player_id]
        
        # Can attach energy?
        if not game_state.energy_attached_this_turn:
            for card in player.hand:
                if card.card_type == "energy":
                    for target in self.get_energy_targets(game_state):
                        actions.append(AttachEnergyAction(card.id, target.id))
        
        # Can play Pokemon?
        for card in player.hand:
            if card.card_type == "pokemon" and card.stage == "basic":
                if len(player.bench) < 5:
                    actions.append(PlayPokemonAction(card.id))
        
        # ... more action types
        
        actions.append(PassAction())  # Can always pass
        return actions

Difficulty Implementations

# core/ai/easy.py
class EasyAI(AIPlayer):
    """Random legal moves with basic priorities."""
    
    async def choose_action(self, game_state: GameState) -> Action:
        actions = self.get_legal_actions(game_state)
        
        # Slight preference: attack if possible
        attacks = [a for a in actions if isinstance(a, AttackAction)]
        if attacks and random.random() > 0.3:
            return random.choice(attacks)
        
        return random.choice(actions)


# core/ai/medium.py
class MediumAI(AIPlayer):
    """Heuristic-based decision making."""
    
    async def choose_action(self, game_state: GameState) -> Action:
        actions = self.get_legal_actions(game_state)
        
        # Score each action
        scored = [(self.score_action(game_state, a), a) for a in actions]
        scored.sort(reverse=True, key=lambda x: x[0])
        
        # Pick top action with some randomness
        top_actions = [a for s, a in scored[:3]]
        return random.choice(top_actions)
    
    def score_action(self, state: GameState, action: Action) -> float:
        score = 0.0
        
        if isinstance(action, AttackAction):
            # Prefer knockouts
            damage = self.calculate_damage(state, action)
            target = state.get_defending_pokemon()
            if damage >= target.hp - target.damage:
                score += 100  # Knockout!
            else:
                score += damage * 0.5
        
        elif isinstance(action, PlayPokemonAction):
            # Value bench presence
            score += 10
        
        # ... more heuristics
        
        return score


# core/ai/hard.py
class HardAI(AIPlayer):
    """Monte Carlo Tree Search for strong play."""
    
    def __init__(self, player_id: str, simulations: int = 1000):
        super().__init__(player_id)
        self.simulations = simulations
    
    async def choose_action(self, game_state: GameState) -> Action:
        root = MCTSNode(game_state, None, self.player_id)
        
        for _ in range(self.simulations):
            node = self.select(root)
            node = self.expand(node)
            result = self.simulate(node)
            self.backpropagate(node, result)
        
        # Return most visited child's action
        best_child = max(root.children, key=lambda c: c.visits)
        return best_child.action

Security Considerations

Hidden Information

Never expose to clients:

  • Deck order (either player)
  • Opponent's hand contents
  • Unrevealed prize cards
  • RNG seeds or future random results
def get_visible_state(game: GameState, player_id: str) -> VisibleGameState:
    """Filter game state to only what player can see."""
    opponent_id = get_opponent(game, player_id)
    
    return VisibleGameState(
        # My full information
        my_hand=[card.to_dict() for card in game.players[player_id].hand],
        my_deck_count=len(game.players[player_id].deck),
        my_prizes=[
            card.to_dict() if card.revealed else {"hidden": True}
            for card in game.players[player_id].prizes
        ],
        
        # Opponent's hidden information
        opponent_hand_count=len(game.players[opponent_id].hand),
        opponent_deck_count=len(game.players[opponent_id].deck),
        opponent_prizes_remaining=len([
            p for p in game.players[opponent_id].prizes if not p.taken
        ]),
        
        # Public information
        battlefield=game.battlefield.to_dict(),
        discard_piles={
            pid: [card.to_dict() for card in player.discard]
            for pid, player in game.players.items()
        },
        current_turn=game.current_turn,
        phase=game.phase,
    )

Server Authority

  • All game logic runs server-side
  • Client sends intentions, server validates and executes
  • Never trust client-provided game state
  • RNG uses secrets module for unpredictability

Input Validation

async def validate_action(game: GameState, player_id: str, action: dict) -> ValidationResult:
    # Is it this player's turn?
    if game.current_turn != player_id:
        return ValidationResult(False, "Not your turn")
    
    # Is this action type valid for current phase?
    action_type = action.get("type")
    if action_type not in VALID_ACTIONS_BY_PHASE[game.phase]:
        return ValidationResult(False, f"Cannot {action_type} during {game.phase}")
    
    # Validate specific action requirements
    validator = ACTION_VALIDATORS.get(action_type)
    if validator:
        return await validator(game, player_id, action)
    
    return ValidationResult(False, "Unknown action type")