- 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
28 KiB
28 KiB
Mantimon TCG - Technical Architecture
This document provides a detailed technical overview of the Mantimon TCG architecture.
Table of Contents
- System Overview
- Frontend Architecture
- Backend Architecture
- Database Schema
- Real-Time Communication
- Game Engine
- AI System
- 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
secretsmodule 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")