Preserves the working F3 Phaser demo implementation before resetting the main frontend/ directory for a fresh start. The POC demonstrates: - Vue 3 + Phaser 3 integration - Real card rendering with images - Vue-Phaser state sync via gameBridge - Card interactions and damage counters To restore: copy .claude/frontend-poc/ back to frontend/ and run npm install
14 KiB
14 KiB
Mantimon TCG Frontend - AI Agent Guidelines
Guidelines for AI agents working on the frontend codebase.
Tech Stack
| Technology | Purpose |
|---|---|
| Vue 3 | UI framework (Composition API + <script setup>) |
| Phaser 3 | Game canvas (matches, pack opening) |
| TypeScript | Type safety (strict mode) |
| Pinia | State management |
| Tailwind CSS | Styling (mobile-first) |
| Socket.io-client | Real-time communication |
| Vite | Build tool |
| Vitest | Unit/component testing |
Quick Commands
npm run dev # Start dev server
npm run build # Production build
npm run preview # Preview production build
npm run test # Run tests
npm run test:watch # Run tests in watch mode
npm run lint # ESLint
npm run typecheck # TypeScript check
Project Structure
src/
├── api/ # API client and composables
│ ├── client.ts # Base HTTP client with auth
│ └── types.ts # API response types
├── assets/ # Static assets, global CSS
├── components/ # Reusable Vue components
│ ├── ui/ # Generic UI (Button, Modal, Toast)
│ ├── cards/ # Card display components
│ ├── deck/ # Deck builder components
│ └── game/ # Game UI overlays
├── composables/ # Vue composables (useAuth, useDecks, etc.)
├── game/ # Phaser game code
│ ├── scenes/ # Phaser scenes
│ ├── objects/ # Game objects (Card, Board)
│ ├── animations/ # Animation helpers
│ └── bridge.ts # Vue-Phaser communication
├── layouts/ # Layout components
├── pages/ # Route pages
├── router/ # Vue Router config
├── socket/ # Socket.IO client
├── stores/ # Pinia stores
├── types/ # TypeScript types
└── utils/ # Utility functions
Code Style
Vue Components
Single File Component structure:
<script setup lang="ts">
// 1. Imports (Vue, then external, then local)
import { ref, computed, onMounted } from 'vue'
import { useGameStore } from '@/stores/game'
import type { Card } from '@/types'
// 2. Props and emits
const props = defineProps<{
cardId: string
size?: 'sm' | 'md' | 'lg'
}>()
const emit = defineEmits<{
click: [cardId: string]
}>()
// 3. Composables and stores
const gameStore = useGameStore()
// 4. Reactive state
const isHovered = ref(false)
// 5. Computed
const card = computed(() => gameStore.getCard(props.cardId))
// 6. Methods
function handleClick() {
emit('click', props.cardId)
}
// 7. Lifecycle
onMounted(() => {
// ...
})
</script>
<template>
<!-- Single root element preferred -->
<div
class="card"
:class="{ 'card--hovered': isHovered }"
@click="handleClick"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<slot />
</div>
</template>
<style scoped>
/* Component-specific styles if Tailwind isn't enough */
</style>
TypeScript
Use strict typing:
// Good - explicit types
interface CardProps {
cardId: string
size?: 'sm' | 'md' | 'lg'
}
// Good - type imports
import type { Card, GameState } from '@/types'
// Good - const assertions for literals
const SIZES = ['sm', 'md', 'lg'] as const
type Size = typeof SIZES[number]
// Avoid - any
const data: any = response // NO
const data: unknown = response // OK if you'll narrow it
Naming Conventions
| Type | Convention | Example |
|---|---|---|
| Components | PascalCase | CardDisplay.vue, DeckBuilder.vue |
| Composables | camelCase with use prefix |
useAuth.ts, useDeckValidation.ts |
| Stores | camelCase with descriptive name | auth.ts, game.ts |
| Types/Interfaces | PascalCase | Card, GameState, ApiResponse |
| Constants | UPPER_SNAKE_CASE | MAX_HAND_SIZE, API_BASE_URL |
| CSS classes | kebab-case (BEM optional) | card-display, card-display--active |
Pinia Stores
Use setup store syntax:
// stores/game.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { GameState } from '@/types'
export const useGameStore = defineStore('game', () => {
// State
const gameState = ref<GameState | null>(null)
const isConnected = ref(false)
// Getters (computed)
const myHand = computed(() => gameState.value?.myHand ?? [])
const isMyTurn = computed(() => gameState.value?.currentPlayer === 'me')
// Actions
function setGameState(state: GameState) {
gameState.value = state
}
function clearGame() {
gameState.value = null
isConnected.value = false
}
return {
// State
gameState,
isConnected,
// Getters
myHand,
isMyTurn,
// Actions
setGameState,
clearGame,
}
})
Composables
Pattern for API composables:
// composables/useDecks.ts
import { ref } from 'vue'
import { apiClient } from '@/api/client'
import type { Deck, DeckCreate } from '@/types'
export function useDecks() {
const decks = ref<Deck[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
async function fetchDecks() {
isLoading.value = true
error.value = null
try {
decks.value = await apiClient.get<Deck[]>('/api/decks')
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to fetch decks'
} finally {
isLoading.value = false
}
}
async function createDeck(data: DeckCreate) {
isLoading.value = true
error.value = null
try {
const deck = await apiClient.post<Deck>('/api/decks', data)
decks.value.push(deck)
return deck
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to create deck'
throw e
} finally {
isLoading.value = false
}
}
return {
decks,
isLoading,
error,
fetchDecks,
createDeck,
}
}
Vue-Phaser Integration
Phaser is for rendering only. Game logic lives in backend.
Communication Pattern
// Vue -> Phaser (intentions)
phaserGame.value?.events.emit('card:play', { cardId, targetZone })
phaserGame.value?.events.emit('attack:select', { attackIndex })
// Phaser -> Vue (completions/UI requests)
phaserGame.value?.events.on('animation:complete', handleAnimationComplete)
phaserGame.value?.events.on('card:clicked', handleCardClicked)
State Sync
// Phaser scene reads from Pinia store
import { useGameStore } from '@/stores/game'
class MatchScene extends Phaser.Scene {
private gameStore = useGameStore()
update() {
// React to store changes
if (this.gameStore.gameState) {
this.renderState(this.gameStore.gameState)
}
}
}
Tailwind Guidelines
Mobile-first responsive:
<!-- Base styles for mobile, override for larger screens -->
<div class="p-2 md:p-4 lg:p-6">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<!-- ... -->
</div>
</div>
Use design system colors:
<!-- Pokemon type colors defined in tailwind.config.js -->
<div class="bg-type-fire text-white">Fire Type</div>
<div class="bg-type-water text-white">Water Type</div>
<!-- UI colors -->
<button class="bg-primary hover:bg-primary-dark">Action</button>
Testing
Component tests with Vitest + Vue Test Utils:
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import CardDisplay from '@/components/cards/CardDisplay.vue'
describe('CardDisplay', () => {
it('renders card name', () => {
/**
* Test that the card component displays the card name.
*
* Card names must be visible for players to identify cards
* in their hand and on the board.
*/
const wrapper = mount(CardDisplay, {
props: { card: { id: '1', name: 'Pikachu', hp: 60 } }
})
expect(wrapper.text()).toContain('Pikachu')
})
it('emits click event with card id', async () => {
/**
* Test that clicking a card emits the correct event.
*
* Card clicks drive all game interactions - playing cards,
* selecting targets, viewing details.
*/
const wrapper = mount(CardDisplay, {
props: { card: { id: '1', name: 'Pikachu', hp: 60 } }
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')?.[0]).toEqual(['1'])
})
})
Critical Rules
- Mobile-first - Base styles for mobile, use
md:andlg:for larger screens - TypeScript strict - No
any, explicit types for props/emits/returns - Composition API only - No Options API, use
<script setup> - Phaser for rendering - No game logic in Phaser, only visualization
- Backend is authoritative - Never trust client state for game logic
- Test docstrings - Every test needs a docstring explaining what and why
Path Aliases
// Use @/ for src/
import { useAuth } from '@/composables/useAuth'
import CardDisplay from '@/components/cards/CardDisplay.vue'
import type { Card } from '@/types'
// Don't use relative paths for deeply nested imports
import { useAuth } from '../../../composables/useAuth' // NO
Environment Variables
# .env.development
VITE_API_BASE_URL=http://localhost:8000
VITE_WS_URL=http://localhost:8000
# .env.production
VITE_API_BASE_URL=https://api.pocket.manticorum.com
VITE_WS_URL=https://api.pocket.manticorum.com
Access in code:
const apiUrl = import.meta.env.VITE_API_BASE_URL
Common Patterns
Loading States
<template>
<div v-if="isLoading" class="flex justify-center p-8">
<LoadingSpinner />
</div>
<div v-else-if="error" class="text-error p-4">
{{ error }}
</div>
<div v-else>
<!-- Content -->
</div>
</template>
Route Guards
// router/guards.ts
export function requireAuth(to, from, next) {
const auth = useAuthStore()
if (!auth.isAuthenticated) {
next({ name: 'Login', query: { redirect: to.fullPath } })
} else {
next()
}
}
API Error Handling
try {
await apiClient.post('/api/games', data)
} catch (e) {
if (e instanceof ApiError) {
if (e.status === 401) {
// Token expired, redirect to login
auth.logout()
} else {
toast.error(e.message)
}
}
}
Backend API Reference
REST Endpoints
Auth (/api/auth/)
| Method | Endpoint | Description |
|---|---|---|
| GET | /auth/google |
Start Google OAuth (redirect) |
| GET | /auth/google/callback |
Google OAuth callback |
| GET | /auth/discord |
Start Discord OAuth (redirect) |
| GET | /auth/discord/callback |
Discord OAuth callback |
| POST | /auth/refresh |
Refresh access token |
| POST | /auth/logout |
Revoke refresh token |
| POST | /auth/logout-all |
Revoke all user tokens |
| GET | /auth/link/google |
Link Google account (authed) |
| GET | /auth/link/discord |
Link Discord account (authed) |
Users (/api/users/)
| Method | Endpoint | Description |
|---|---|---|
| GET | /users/me |
Get current user profile |
| PATCH | /users/me |
Update profile (display_name, avatar_url) |
| GET | /users/me/linked-accounts |
List linked OAuth accounts |
| DELETE | /users/me/link/{provider} |
Unlink OAuth provider |
| GET | /users/me/sessions |
Get active session count |
| GET | /users/me/starter-status |
Check if starter deck selected |
| POST | /users/me/starter-deck |
Select starter deck |
Collections (/api/collections/)
| Method | Endpoint | Description |
|---|---|---|
| GET | /collections/me |
Get user's card collection |
| GET | /collections/me/cards/{card_id} |
Get specific card quantity |
Decks (/api/decks/)
| Method | Endpoint | Description |
|---|---|---|
| GET | /decks |
List user's decks |
| POST | /decks |
Create new deck |
| GET | /decks/{id} |
Get deck by ID |
| PUT | /decks/{id} |
Update deck |
| DELETE | /decks/{id} |
Delete deck |
| POST | /decks/validate |
Validate deck without saving |
Games (/api/games/)
| Method | Endpoint | Description |
|---|---|---|
| POST | /games |
Create new game |
| GET | /games/{id} |
Get game info |
| GET | /games/me/active |
List user's active games |
| POST | /games/{id}/resign |
Resign from game (HTTP fallback) |
WebSocket Events (Socket.IO)
Namespace: /game
Client → Server:
| Event | Payload | Description |
|---|---|---|
game:join |
{ game_id } |
Join/rejoin a game room |
game:action |
{ action_type, ...data } |
Execute game action |
game:resign |
{ game_id } |
Resign from game |
game:spectate |
{ game_id } |
Join as spectator |
game:leave_spectate |
{ game_id } |
Leave spectator mode |
Server → Client:
| Event | Payload | Description |
|---|---|---|
game:state |
GameState |
Full game state (visibility filtered) |
game:action_result |
ActionResult |
Result of player action |
game:error |
{ message, code } |
Error notification |
game:turn_timeout_warning |
{ remaining_seconds } |
Turn timer warning |
game:game_over |
{ winner, reason } |
Game ended |
game:opponent_connected |
{ connected } |
Opponent connection status |
game:reconnected |
{ game_id, state } |
Auto-reconnected to game |
game:spectator_count |
{ count } |
Spectator count update |
Key Backend Files
| File | Description |
|---|---|
backend/app/api/auth.py |
Auth endpoints implementation |
backend/app/api/users.py |
User endpoints implementation |
backend/app/api/games.py |
Game REST endpoints |
backend/app/schemas/ |
Pydantic request/response schemas |
backend/app/schemas/ws_messages.py |
WebSocket message schemas |
backend/app/socketio/game_namespace.py |
WebSocket event handlers |
Type Definitions to Mirror
When creating frontend types, reference these backend schemas:
backend/app/schemas/user.py→UserResponse,UserUpdatebackend/app/schemas/deck.py→DeckResponse,DeckCreate,DeckUpdatebackend/app/schemas/game.py→GameCreateRequest,GameResponsebackend/app/schemas/ws_messages.py→ All WebSocket message types
See Also
PROJECT_PLAN_FRONTEND.json- Phase plan and tasks../backend/CLAUDE.md- Backend guidelines../backend/app/schemas/- API schema definitions../backend/app/socketio/README.md- WebSocket documentation../CLAUDE.md- Project-wide guidelines