mantimon-tcg/CLAUDE.md
Cal Corum 46e7420395 Add RPG campaign structure inspired by GBC Pokemon TCG
- Define campaign loop: Clubs → Leaders → Medals → Grand Masters → Champion
- Update PROJECT_PLAN with campaign as core experience, multiplayer as optional
- Add Campaign Structure section to GAME_RULES with clubs, NPCs, rewards
- Reorganize development phases to prioritize campaign mode
- Update CLAUDE.md project overview and uv commands
2026-01-24 18:22:36 -06:00

8.9 KiB

CLAUDE.md - Mantimon TCG

Guidelines for AI coding agents working on this project.

Quick Reference

Frontend dev server: cd frontend && npm run dev Backend dev server: cd backend && uv run uvicorn app.main:app --reload Run frontend tests: cd frontend && npm run test Run backend tests: cd backend && uv run pytest Type check frontend: cd frontend && npm run typecheck Lint frontend: cd frontend && npm run lint

Project Overview

Mantimon TCG is a home-rule-modified Pokemon Trading Card Game web application inspired by the Gameboy Color game Pokemon TCG. The core experience is a single-player RPG campaign:

  • Campaign Mode: Challenge NPCs at themed clubs, defeat Club Leaders to earn medals, collect all medals to face Grand Masters and become Champion
  • Collection Building: Win matches to earn booster packs, build your card collection
  • Deck Building: Construct decks from your collection to take on tougher opponents
  • Multiplayer (Optional): PvP matches for competitive play

Tech Stack

Frontend

Technology Purpose
Vue 3 UI framework (Composition API + <script setup>)
Phaser 3 Game canvas (matches, pack opening)
TypeScript Type safety
Pinia State management
Tailwind CSS Styling
Socket.io-client Real-time communication
Vite Build tool

Backend

Technology Purpose
FastAPI REST API framework
Python 3.11+ Backend language
SQLAlchemy 2.0 ORM (async)
PostgreSQL Database
Redis Caching, session storage
python-socketio WebSocket server
Pydantic v2 Validation
Alembic Database migrations

Code Style

General

  • Line length: 100 characters max
  • Indentation: 2 spaces (frontend), 4 spaces (backend)
  • Trailing commas in multi-line structures
  • Explicit over implicit

TypeScript/Vue

// Imports: stdlib, third-party, local (separated by blank lines)
import { ref, computed } from 'vue'

import { useGameStore } from '@/stores/game'
import type { Card, GameState } from '@/types'

// Always use type imports for types
import type { Player } from '@/types/player'

// Prefer const over let
const cards = ref<Card[]>([])

// Use descriptive names
const isPlayerTurn = computed(() => gameStore.currentPlayer === playerId)

// Component naming: PascalCase
// File naming: PascalCase for components, camelCase for utilities

Python

# Imports: stdlib, third-party, local (separated by blank lines)
from typing import Optional

from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession

from app.models import Card, Player
from app.services.game_service import GameService

# Type hints required for all function signatures
async def get_card(card_id: int, db: AsyncSession = Depends(get_db)) -> Card:
    ...

# Use Pydantic models for request/response
class PlayCardRequest(BaseModel):
    card_id: int
    target_id: Optional[int] = None

# Async by default for I/O operations
async def resolve_attack(attacker: Card, defender: Card) -> AttackResult:
    ...

Naming Conventions

Type Convention Example
Vue components PascalCase CardHand.vue, GameBoard.vue
Phaser scenes PascalCase MatchScene.ts, PackOpeningScene.ts
TypeScript files camelCase useWebSocket.ts, cardUtils.ts
Python modules snake_case game_engine.py, card_service.py
Database tables snake_case user_collections, match_history
Constants UPPER_SNAKE_CASE MAX_HAND_SIZE, PRIZE_COUNT

Architecture Patterns

Frontend: Vue + Phaser Integration

Phaser is mounted as a Vue component. Communication happens via:

// In Vue component
const phaserGame = ref<Phaser.Game | null>(null)

// Emit events to Phaser
phaserGame.value?.events.emit('card:play', { cardId, targetId })

// Listen to events from Phaser
phaserGame.value?.events.on('animation:complete', handleAnimationComplete)

Frontend: State Management

// Use Pinia stores for global state
// stores/game.ts
export const useGameStore = defineStore('game', () => {
  const gameState = ref<GameState | null>(null)
  const myHand = computed(() => gameState.value?.myHand ?? [])
  
  function setGameState(state: GameState) {
    gameState.value = state
  }
  
  return { gameState, myHand, setGameState }
})

Backend: Service Layer

Never bypass services for business logic:

# CORRECT
card = await card_service.get_card(card_id)
result = await game_service.play_card(game_id, player_id, card_id)

# WRONG - direct DB access in endpoint
card = await db.execute(select(Card).where(Card.id == card_id))

Backend: WebSocket Events

# All game actions go through WebSocket for real-time sync
@sio.on('game:action')
async def handle_game_action(sid, data):
    action_type = data['type']
    
    # Validate action is legal
    validation = await game_engine.validate_action(game_id, player_id, data)
    if not validation.valid:
        await sio.emit('game:error', {'message': validation.reason}, to=sid)
        return
    
    # Execute and broadcast
    new_state = await game_engine.execute_action(game_id, data)
    await broadcast_game_state(game_id, new_state)

Game Engine Patterns

Card Effect System

Cards are data-driven. Effects reference handler functions:

# Card definition (JSON/DB)
{
    "id": "pikachu_base_001",
    "name": "Pikachu",
    "hp": 60,
    "type": "lightning",
    "attacks": [
        {
            "name": "Thunder Shock",
            "cost": ["lightning"],
            "damage": 20,
            "effect": "may_paralyze",  # References effect handler
            "effect_params": {"chance": 0.5}
        }
    ]
}

# Effect handler (Python)
@effect_handler("may_paralyze")
async def handle_may_paralyze(context: EffectContext, params: dict) -> None:
    if random.random() < params["chance"]:
        context.defender.add_status("paralyzed")

Turn State Machine

class TurnPhase(Enum):
    DRAW = "draw"
    MAIN = "main"
    ATTACK = "attack"
    END = "end"

# Transitions are explicit
VALID_TRANSITIONS = {
    TurnPhase.DRAW: [TurnPhase.MAIN],
    TurnPhase.MAIN: [TurnPhase.ATTACK, TurnPhase.END],  # Can skip attack
    TurnPhase.ATTACK: [TurnPhase.END],
    TurnPhase.END: [TurnPhase.DRAW],  # Next player's turn
}

Hidden Information

Critical: Server never sends hidden information to clients.

def get_visible_state(game: Game, player_id: str) -> VisibleGameState:
    return VisibleGameState(
        my_hand=game.players[player_id].hand,  # Full hand
        my_prizes=game.players[player_id].prizes,  # Can see own prizes
        my_deck_count=len(game.players[player_id].deck),  # Only count
        
        opponent_hand_count=len(opponent.hand),  # ONLY count
        opponent_prizes_count=len(opponent.prizes),  # ONLY count
        opponent_deck_count=len(opponent.deck),  # ONLY count
        
        battlefield=game.battlefield,  # Public
        discard_piles=game.discard_piles,  # Public
    )

Testing

Frontend (Vitest)

import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import CardHand from '@/components/CardHand.vue'

describe('CardHand', () => {
  it('renders cards in hand', () => {
    const wrapper = mount(CardHand, {
      props: {
        cards: [{ id: '1', name: 'Pikachu' }]
      }
    })
    expect(wrapper.text()).toContain('Pikachu')
  })
})

Backend (pytest)

import pytest
from app.core.game_engine import GameEngine

@pytest.mark.asyncio
async def test_draw_card():
    """
    Test that drawing a card moves it from deck to hand.
    
    Verifies the fundamental draw mechanic works correctly
    and updates both zones appropriately.
    """
    engine = GameEngine()
    game = await engine.create_game(player_ids=["p1", "p2"])
    
    initial_deck_size = len(game.players["p1"].deck)
    initial_hand_size = len(game.players["p1"].hand)
    
    await engine.draw_card(game, "p1")
    
    assert len(game.players["p1"].deck) == initial_deck_size - 1
    assert len(game.players["p1"].hand) == initial_hand_size + 1

Directory-Specific Guidelines

See additional CLAUDE.md files in subdirectories:

  • frontend/CLAUDE.md - Vue/Phaser specifics
  • backend/CLAUDE.md - FastAPI/game engine specifics
  • backend/app/core/CLAUDE.md - Game engine architecture

Critical Rules

  1. Git: Never commit directly to main. Create feature branches.
  2. Hidden Info: Never send deck contents, opponent hand, or unrevealed prizes to client.
  3. Validation: Always validate actions server-side. Never trust client.
  4. Tests: Include docstrings explaining "what" and "why" for each test.
  5. Commits: Do not commit without user approval.
  6. Phaser in Vue: Keep Phaser scenes focused on rendering. Game logic lives in backend.