# 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