# Mantimon TCG - Technical Architecture This document provides a detailed technical overview of the Mantimon TCG architecture. ## Table of Contents 1. [System Overview](#system-overview) 2. [Frontend Architecture](#frontend-architecture) 3. [Backend Architecture](#backend-architecture) 4. [Database Schema](#database-schema) 5. [Real-Time Communication](#real-time-communication) 6. [Game Engine](#game-engine) 7. [AI System](#ai-system) 8. [Security Considerations](#security-considerations) 9. [Offline Standalone Fork](#offline-standalone-fork) --- ## 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 ` ``` ### 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) ```typescript // stores/game.ts export const useGameStore = defineStore('game', () => { // State const gameState = ref(null) const pendingAction = ref(null) const socket = ref(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 ```python # 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 ```python 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 ```python # 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: ```python # 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 ```python # 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 ```python # 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 ```python 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 ```python 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") ``` --- ## Offline Standalone Fork > **Design Consideration**: The architecture intentionally supports forking as a completely offline, standalone experience for the RPG/campaign mode. ### Why This Matters The primary gameplay experience is a single-player RPG campaign. While the live service provides multiplayer and cloud saves, many players may prefer: - Offline play without internet dependency - Local save files they fully control - A self-contained executable with no account requirements - Modding and customization freedom ### Architecture Principles for Fork Compatibility The `backend/app/core/` game engine is designed to be **completely decoupled** from network and database concerns: | Component | Live Service | Offline Fork | |-----------|--------------|--------------| | Game Engine (`core/`) | Same | Same (copy directly) | | Card Definitions | PostgreSQL | Embedded JSON files | | Save Data | PostgreSQL + Redis | Local JSON/SQLite files | | Authentication | OAuth/JWT | None needed | | Multiplayer | WebSocket/Socket.io | Removed entirely | | RNG | SecureRandom | SeededRandom (for replays) | ### Core Engine Independence The game engine has **zero dependencies** on: - Database connections - Network I/O - Authentication/sessions - Redis caching - WebSocket communication All it needs: - `RulesConfig` (can be hardcoded or loaded from JSON) - `CardDefinition` objects (can be embedded or loaded from files) - `RandomProvider` (SeededRandom works offline) ### Potential Offline Package Structure ``` mantimon-offline/ ├── game/ │ ├── core/ # Direct copy of backend/app/core/ │ ├── campaign/ # NPC definitions, dialog, progression │ │ ├── clubs/ # Club data (NPCs, leaders, rewards) │ │ ├── dialog/ # NPC dialog trees │ │ └── progression.py # Campaign state machine │ ├── cards/ # Card definitions as JSON │ │ ├── base_set.json │ │ └── expansion_1.json │ └── saves/ # Local save directory │ └── slot_1.json ├── ui/ # Could reuse Phaser or use PyGame/Godot ├── assets/ # Card images, sounds └── main.py # Entry point ``` ### Packaging Options for Distribution | Approach | Bundle Size | Complexity | UI Reuse | |----------|-------------|------------|----------| | **Electron + Python** | ~150MB | Medium | Full web UI | | **Tauri + Python** | ~30MB | High | Full web UI | | **PyInstaller + PyGame** | ~50MB | Low | New UI needed | | **Godot + GDScript port** | ~40MB | Medium | New UI needed | | **Nuitka + embedded browser** | ~80MB | Medium | Phaser reuse | ### What Stays, What Goes **Keep (copy to fork):** - `backend/app/core/` - Entire game engine - `frontend/src/game/` - Phaser scenes (if using web UI) - Card definitions (export from DB to JSON) - Campaign/NPC data **Remove (not needed offline):** - `backend/app/api/` - REST endpoints - `backend/app/websocket/` - Multiplayer sync - `backend/app/services/` - DB-backed services - Authentication system - Matchmaking/lobby ### Development Guidelines To maintain fork compatibility, follow these rules when developing the core engine: 1. **No imports from `app.api`, `app.websocket`, or `app.services`** in `app.core` 2. **No database session dependencies** in core engine functions 3. **Card definitions passed in**, not fetched - `GameEngine.create_game()` receives a card registry 4. **RNG injected via protocol** - `RandomProvider` allows swapping implementations 5. **State is self-contained** - `GameState` includes everything needed to resume a game 6. **No async required** - Core logic is sync; async wrappers added at service layer ### Testing Offline Viability The existing test suite validates offline compatibility: - All `tests/core/` tests run without database - `SeededRandom` enables deterministic testing - Card fixtures are created in-memory, not loaded from DB ### Future: Official Offline Release If demand exists, an official offline version could be released: 1. Export campaign data and card definitions to JSON 2. Bundle core engine with a lightweight UI wrapper 3. Distribute as standalone executable (itch.io, Steam, etc.) 4. Optional: Steam Workshop for community card sets