Files
SerpentRace/Documentations/FRONTEND_IMPLEMENTATION_GUIDE.md
Donat 86211923db Backend Complete: Interface Refactoring & Service Container Enhancements
Repository Interface Optimization:
- Created IBaseRepository.ts and IPaginatedRepository.ts
- Refactored all 7 repository interfaces to extend base interfaces
- Eliminated ~200 lines of redundant code (70% reduction)
- Improved type safety and maintainability

 Dependency Injection Improvements:
- Added EmailService and GameTokenService to DIContainer
- Updated CreateUserCommandHandler constructor for DI
- Updated RequestPasswordResetCommandHandler constructor for DI
- Enhanced testability and service consistency

 Environment Configuration:
- Created comprehensive .env.example with 40+ variables
- Organized into 12 logical sections (Database, Security, Email, etc.)
- Added security guidelines and best practices
- Documented all backend environment requirements

 Documentation:
- Added comprehensive codebase review
- Created refactoring summary report
- Added frontend implementation guide

Impact: Improved code quality, reduced maintenance overhead, enhanced developer experience
2025-09-21 03:27:57 +02:00

22 KiB

🎮 SerpentRace Frontend Developer Guide

📋 Table of Contents

  1. Quick Start
  2. Authentication System
  3. Game Integration
  4. API Reference
  5. WebSocket Events
  6. Data Models
  7. Error Handling
  8. Performance Tips
  9. Security Guidelines
  10. Troubleshooting

🚀 Quick Start

Base Configuration

// config.ts
export const API_CONFIG = {
  baseURL: 'http://localhost:3000/api',
  wsURL: 'http://localhost:3000',
  timeout: 10000,
  retryAttempts: 3
};

API Client Setup

// apiClient.ts
import axios from 'axios';

const apiClient = axios.create({
  baseURL: API_CONFIG.baseURL,
  timeout: API_CONFIG.timeout,
  withCredentials: true, // Important for cookie-based auth
  headers: {
    'Content-Type': 'application/json'
  }
});

// Request interceptor for auth token
apiClient.interceptors.request.use((config) => {
  const token = localStorage.getItem('auth_token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Response interceptor for token refresh
apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      // Handle token expiration
      localStorage.removeItem('auth_token');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

🔐 Authentication System

1. User Registration

interface RegisterRequest {
  username: string;
  email: string;
  password: string;
  fname?: string;
  lname?: string;
  phone?: string;
}

async function registerUser(userData: RegisterRequest) {
  const response = await apiClient.post('/users/create', userData);
  return response.data; // Returns user data without password
}

2. User Login

interface LoginRequest {
  username: string;
  password: string;
}

interface LoginResponse {
  token: string;
  user: {
    id: string;
    username: string;
    email: string;
    state: number; // 0=NOT_VERIFIED, 1=VERIFIED_REGULAR, 2=VERIFIED_PREMIUM, 3=ADMIN
    orgId?: string;
  };
}

async function loginUser(credentials: LoginRequest): Promise<LoginResponse> {
  const response = await apiClient.post('/users/login', credentials);
  
  // Store token for future requests
  localStorage.setItem('auth_token', response.data.token);
  
  return response.data;
}

3. Token Management

class AuthManager {
  private token: string | null = null;

  setToken(token: string) {
    this.token = token;
    localStorage.setItem('auth_token', token);
  }

  getToken(): string | null {
    return this.token || localStorage.getItem('auth_token');
  }

  clearToken() {
    this.token = null;
    localStorage.removeItem('auth_token');
  }

  isAuthenticated(): boolean {
    return !!this.getToken();
  }
}

export const authManager = new AuthManager();

🎮 Game Integration

1. Create Game

interface CreateGameRequest {
  deckids: string[];     // Array of deck UUIDs
  maxplayers: number;    // 2-8 players
  logintype: number;     // 0=PUBLIC, 1=PRIVATE, 2=ORGANIZATION
}

interface GameResponse {
  id: string;
  gamecode: string;      // 6-character join code
  maxplayers: number;
  state: number;         // 0=WAITING, 1=ACTIVE, 2=FINISHED, 3=CANCELLED
  players: string[];
  gameToken?: string;    // For immediate joining
}

async function createGame(gameData: CreateGameRequest): Promise<GameResponse> {
  const response = await apiClient.post('/games/start', gameData);
  return response.data;
}

2. Join Game

interface JoinGameRequest {
  gameCode: string;      // 6-character code
  playerName?: string;   // Required for public games, optional for authenticated
}

interface JoinGameResponse extends GameResponse {
  gameToken: string;     // Use this for WebSocket authentication
  playerName: string;
  isGamemaster: boolean;
  pendingApproval?: boolean; // True for private games awaiting approval
}

async function joinGame(joinData: JoinGameRequest): Promise<JoinGameResponse> {
  const response = await apiClient.post('/games/join', joinData);
  return response.data;
}

3. WebSocket Game Connection

import io, { Socket } from 'socket.io-client';

class GameClient {
  private gameSocket: Socket | null = null;
  private gameToken: string = '';
  private eventListeners = new Map<string, Function>();

  async connectToGame(gameToken: string): Promise<void> {
    this.gameToken = gameToken;
    
    // Connect to game namespace
    this.gameSocket = io('/game', {
      transports: ['websocket']
    });

    this.setupEventHandlers();

    // Join specific game with token
    this.gameSocket.emit('game:join', { gameToken });

    return new Promise((resolve, reject) => {
      this.gameSocket!.once('game:joined', (data) => {
        console.log('Successfully joined game:', data);
        resolve();
      });

      this.gameSocket!.once('game:error', (error) => {
        console.error('Game connection error:', error);
        reject(new Error(error.message));
      });
    });
  }

  private setupEventHandlers() {
    if (!this.gameSocket) return;

    // Game state updates
    this.addListener('game:state-update', (gameState) => {
      console.log('Game state updated:', gameState);
      // Update UI with new game state
    });

    // Player movements
    this.addListener('game:player-moved', (moveData) => {
      console.log('Player moved:', moveData);
      // Update board visualization
    });

    // Field effects
    this.addListener('game:field-effect', (effectData) => {
      console.log('Field effect triggered:', effectData);
      // Show effect animation/notification
    });

    // Chat messages
    this.addListener('game:chat-message', (chatData) => {
      console.log('Game chat:', chatData);
      // Display chat message
    });
  }

  addListener(event: string, handler: Function) {
    if (!this.gameSocket) return;
    
    this.gameSocket.on(event, handler);
    this.eventListeners.set(event, handler);
  }

  removeAllListeners() {
    this.eventListeners.forEach((handler, event) => {
      this.gameSocket?.off(event, handler);
    });
    this.eventListeners.clear();
  }

  rollDice(diceValue: number) {
    if (!this.gameSocket) return;
    
    this.gameSocket.emit('game:dice-roll', {
      gameCode: this.gameCode, // Extract from gameToken
      diceValue
    });
  }

  sendChatMessage(message: string) {
    if (!this.gameSocket) return;
    
    this.gameSocket.emit('game:chat', {
      gameCode: this.gameCode,
      message
    });
  }

  disconnect() {
    this.removeAllListeners();
    this.gameSocket?.disconnect();
    this.gameSocket = null;
  }
}

4. Private Game Approval Flow

// For gamemaster - handle approval requests
gameSocket.on('game:player-requesting-join', (data) => {
  console.log('Player requesting to join:', data);
  // Show approval UI with player name
  showApprovalDialog(data.playerName, data.gameCode);
});

function approvePlayer(gameCode: string, playerName: string) {
  gameSocket.emit('game:approve-player', { gameCode, playerName });
}

function rejectPlayer(gameCode: string, playerName: string, reason?: string) {
  gameSocket.emit('game:reject-player', { gameCode, playerName, reason });
}

// For joining player - handle approval response
gameSocket.on('game:pending-approval', (data) => {
  console.log('Waiting for gamemaster approval:', data);
  // Show waiting message
});

gameSocket.on('game:approval-granted', (data) => {
  console.log('Approved! Joining game:', data);
  // Automatically join game rooms
  gameSocket.emit('game:join-approved', { gameToken });
});

gameSocket.on('game:approval-denied', (data) => {
  console.log('Join request denied:', data);
  // Show rejection message and reason
});

📡 API Reference

User Endpoints

Endpoint Method Auth Description
/users/login POST No User authentication
/users/create POST No User registration
/users/logout POST Yes User logout
/users/profile GET Yes Get user profile
/users/profile PATCH Yes Update user profile
/users/verify-email POST No Verify email token
/users/request-password-reset POST No Request password reset
/users/reset-password POST No Reset password with token

Game Endpoints

Endpoint Method Auth Description
/games/start POST Yes Create new game
/games/join POST Optional* Join existing game
/games/{gameId}/start POST Yes Start game (gamemaster only)
/games/my-games GET Yes Get user's games
/games/active GET No Get active public games

*Auth required for private/organization games

Deck Endpoints

Endpoint Method Auth Description
/decks GET Optional Get available decks
/decks POST Yes Create new deck
/decks/{id} GET Optional Get deck details
/decks/{id} PUT Yes Update deck (owner only)
/decks/{id} DELETE Yes Delete deck (owner only)

Organization Endpoints

Endpoint Method Auth Description
/organizations GET Yes Get user's organization
/organizations/{id}/join POST Yes Request to join organization

🔌 WebSocket Events

Connection Events

// Connect to main chat namespace
const chatSocket = io('/', {
  auth: { token: authToken },
  transports: ['websocket']
});

// Connect to game namespace
const gameSocket = io('/game', {
  transports: ['websocket']
});

Game Events (Client → Server)

Event Data Description
game:join { gameToken: string } Join game with token
game:leave { gameCode: string } Leave current game
game:dice-roll { gameCode: string, diceValue: number } Roll dice (1-6)
game:chat { gameCode: string, message: string } Send chat message
game:ready { gameCode: string, ready: boolean } Toggle ready status
game:approve-player { gameCode: string, playerName: string } Approve join request
game:reject-player { gameCode: string, playerName: string, reason?: string } Reject join request

Game Events (Server → Client)

Event Data Description
game:joined GameJoinedData Successfully joined game
game:left GameLeftData Successfully left game
game:player-moved PlayerMoveData Player moved on board
game:field-effect FieldEffectData Field effect triggered
game:chat-message ChatMessageData Game chat message
game:state-update GameStateData Game state changed
game:player-joined PlayerJoinedData New player joined
game:player-left PlayerLeftData Player left game
game:game-started GameStartedData Game started
game:game-ended GameEndedData Game finished
game:error { message: string } Game-related error

📊 Data Models

Game State Model

interface GameState {
  gameId: string;
  gameCode: string;
  state: GameStateEnum; // 0=WAITING, 1=ACTIVE, 2=FINISHED, 3=CANCELLED
  maxPlayers: number;
  currentPlayers: PlayerState[];
  gamemaster: string; // User ID
  board: BoardField[];
  currentTurn?: string; // Player ID whose turn it is
  turnOrder: string[]; // Player IDs in turn sequence
  startedAt?: Date;
  finishedAt?: Date;
  winner?: string; // Player ID
}

interface PlayerState {
  playerId: string;
  playerName: string;
  boardPosition: number; // 0-101 (0=start, 101=finish)
  isReady: boolean;
  isOnline: boolean;
  joinedAt: Date;
  turnOrder: number;
}

interface BoardField {
  position: number; // 1-100
  type: 'regular' | 'positive' | 'negative' | 'luck';
  effect?: string; // Description of field effect
}

Move Data Model

interface PlayerMoveData {
  playerId: string;
  playerName: string;
  diceValue: number;
  oldPosition: number;
  newPosition: number;
  hasWon: boolean;
  cardEffect?: {
    applied: boolean;
    description: string;
    positionChange: number;
    extraTurn: boolean;
    turnEffect?: 'LOSE_TURN' | 'EXTRA_TURN';
    effects: string[];
  };
  timestamp: string;
}

Field Effect Model

interface FieldEffectData {
  playerId: string;
  playerName: string;
  fieldNumber: number;
  card?: GameCard;
  consequence?: {
    type: ConsequenceType;
    value?: number;
    description: string;
  };
  newPosition?: number;
  turnEffect?: 'LOSE_TURN' | 'EXTRA_TURN';
  requiresInput?: boolean;
  inputPrompt?: string;
  timestamp: string;
}

interface GameCard {
  id: string;
  text: string; // Question or content
  type: CardType; // 0=QUIZ, 1=SENTENCE_PAIRING, 2=OWN_ANSWER, 3=TRUE_FALSE, 4=CLOSER
  answer?: string;
  consequence?: {
    type: ConsequenceType; // 0=MOVE_FORWARD, 1=MOVE_BACKWARD, 2=LOSE_TURN, 3=EXTRA_TURN, 5=GO_TO_START
    value?: number;
  };
}

⚠️ Error Handling

API Error Response Format

interface APIError {
  error: string;
  details?: string;
  code?: string;
  timestamp?: string;
}

// Common HTTP Status Codes
// 400 - Bad Request (validation errors)
// 401 - Unauthorized (authentication required)
// 403 - Forbidden (insufficient permissions)
// 404 - Not Found
// 409 - Conflict (duplicate data)
// 500 - Internal Server Error

Error Handling Pattern

async function handleAPICall<T>(apiCall: () => Promise<T>): Promise<T> {
  try {
    return await apiCall();
  } catch (error) {
    if (axios.isAxiosError(error)) {
      const response = error.response;
      
      switch (response?.status) {
        case 400:
          throw new Error(`Validation Error: ${response.data.error}`);
        case 401:
          // Handle authentication error
          authManager.clearToken();
          window.location.href = '/login';
          throw new Error('Authentication required');
        case 403:
          throw new Error(`Access Denied: ${response.data.error}`);
        case 404:
          throw new Error('Resource not found');
        case 409:
          throw new Error(`Conflict: ${response.data.error}`);
        case 500:
          throw new Error('Server error. Please try again later.');
        default:
          throw new Error(`Network error: ${error.message}`);
      }
    }
    throw error;
  }
}

// Usage
try {
  const user = await handleAPICall(() => loginUser(credentials));
  console.log('Login successful:', user);
} catch (error) {
  console.error('Login failed:', error.message);
  showErrorMessage(error.message);
}

WebSocket Error Handling

gameSocket.on('game:error', (error) => {
  console.error('Game error:', error);
  
  switch (error.message) {
    case 'Game not found':
      showError('The game you\'re trying to join no longer exists.');
      break;
    case 'Game is full':
      showError('This game is full. Please try another game.');
      break;
    case 'Invalid or expired game token':
      showError('Your game session has expired. Please rejoin.');
      break;
    default:
      showError(`Game error: ${error.message}`);
  }
});

gameSocket.on('disconnect', (reason) => {
  console.log('Disconnected from game:', reason);
  
  if (reason === 'io server disconnect') {
    // Server disconnected the client
    showError('Disconnected from game server');
  } else {
    // Client disconnected or network issue
    showWarning('Connection lost. Attempting to reconnect...');
  }
});

🚀 Performance Optimization

1. Connection Management

class ConnectionManager {
  private static chatSocket: Socket | null = null;
  private static gameSocket: Socket | null = null;

  static getChatSocket(): Socket {
    if (!this.chatSocket) {
      this.chatSocket = io('/', {
        auth: { token: authManager.getToken() },
        transports: ['websocket']
      });
    }
    return this.chatSocket;
  }

  static getGameSocket(): Socket {
    if (!this.gameSocket) {
      this.gameSocket = io('/game', {
        transports: ['websocket']
      });
    }
    return this.gameSocket;
  }

  static disconnect() {
    this.chatSocket?.disconnect();
    this.gameSocket?.disconnect();
    this.chatSocket = null;
    this.gameSocket = null;
  }
}

2. Event Listener Cleanup

class GameComponent {
  private eventCleanup: (() => void)[] = [];

  componentDidMount() {
    const gameSocket = ConnectionManager.getGameSocket();

    // Track listeners for cleanup
    const addListener = (event: string, handler: Function) => {
      gameSocket.on(event, handler);
      this.eventCleanup.push(() => gameSocket.off(event, handler));
    };

    addListener('game:player-moved', this.handlePlayerMove);
    addListener('game:state-update', this.handleStateUpdate);
  }

  componentWillUnmount() {
    // Clean up all event listeners
    this.eventCleanup.forEach(cleanup => cleanup());
    this.eventCleanup = [];
  }
}

3. API Caching

class APICache {
  private cache = new Map<string, { data: any; timestamp: number; ttl: number }>();

  async get<T>(key: string, fetcher: () => Promise<T>, ttl = 300000): Promise<T> {
    const cached = this.cache.get(key);
    
    if (cached && Date.now() - cached.timestamp < cached.ttl) {
      return cached.data;
    }

    const data = await fetcher();
    this.cache.set(key, { data, timestamp: Date.now(), ttl });
    return data;
  }

  invalidate(pattern?: string) {
    if (pattern) {
      for (const key of this.cache.keys()) {
        if (key.includes(pattern)) {
          this.cache.delete(key);
        }
      }
    } else {
      this.cache.clear();
    }
  }
}

const apiCache = new APICache();

// Usage
const decks = await apiCache.get(
  'available-decks',
  () => apiClient.get('/decks').then(res => res.data),
  300000 // 5 minutes
);

🔒 Security Guidelines

1. Token Security

// ❌ DON'T: Store tokens in localStorage for sensitive apps
localStorage.setItem('auth_token', token);

// ✅ DO: Use secure, httpOnly cookies when possible
// This requires server-side cookie configuration

// ✅ DO: Clear tokens on logout
function logout() {
  localStorage.removeItem('auth_token');
  apiCache.invalidate();
  ConnectionManager.disconnect();
  window.location.href = '/login';
}

2. Input Validation

function validateGameCode(gameCode: string): boolean {
  // Game codes are exactly 6 alphanumeric characters
  return /^[A-Z0-9]{6}$/.test(gameCode);
}

function validatePlayerName(playerName: string): boolean {
  // Player names: 3-50 characters, alphanumeric + spaces
  return /^[a-zA-Z0-9\s]{3,50}$/.test(playerName.trim());
}

function sanitizeMessage(message: string): string {
  // Remove HTML tags and limit length
  return message
    .replace(/<[^>]*>/g, '')
    .substring(0, 500)
    .trim();
}

3. Error Message Security

// ❌ DON'T: Expose sensitive information
console.error('Database error:', fullErrorDetails);

// ✅ DO: Log safely and show user-friendly messages
function handleError(error: any) {
  console.error('API Error:', error.response?.status || 'Unknown');
  
  const userMessage = error.response?.data?.error || 'An unexpected error occurred';
  showUserMessage(userMessage);
}

🔧 Troubleshooting

Common Issues & Solutions

1. WebSocket Connection Failed

// Problem: Cannot connect to WebSocket
// Solution: Check URL and add reconnection logic

const gameSocket = io('/game', {
  transports: ['websocket'],
  timeout: 10000,
  forceNew: true,
  reconnection: true,
  reconnectionAttempts: 5,
  reconnectionDelay: 1000
});

gameSocket.on('connect_error', (error) => {
  console.error('Connection failed:', error);
  showError('Unable to connect to game server. Please check your connection.');
});

2. Authentication Token Expired

// Problem: 401 errors on API calls
// Solution: Implement token refresh or redirect to login

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      console.log('Token expired, redirecting to login');
      authManager.clearToken();
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

3. Game State Out of Sync

// Problem: Game state doesn't match server
// Solution: Request fresh game state

function requestGameStateRefresh(gameCode: string) {
  gameSocket.emit('game:request-state', { gameCode });
}

gameSocket.on('game:state-refresh', (gameState) => {
  console.log('Received fresh game state:', gameState);
  updateGameUI(gameState);
});

4. Memory Leaks in Game Component

// Problem: Event listeners not cleaned up
// Solution: Proper cleanup pattern

useEffect(() => {
  const gameSocket = ConnectionManager.getGameSocket();
  
  const handlers = {
    'game:player-moved': handlePlayerMove,
    'game:state-update': handleStateUpdate,
    'game:chat-message': handleChatMessage
  };

  // Add listeners
  Object.entries(handlers).forEach(([event, handler]) => {
    gameSocket.on(event, handler);
  });

  // Cleanup function
  return () => {
    Object.entries(handlers).forEach(([event, handler]) => {
      gameSocket.off(event, handler);
    });
  };
}, []);

📞 Support & Documentation

Additional Resources

  • API Documentation: Available at /api-docs (Swagger UI)
  • WebSocket Events: Complete event reference in game-websocket-examples.ts
  • Backend Repository: Full source code and additional documentation

Development Tips

  1. Use browser dev tools Network tab to debug API calls
  2. Enable WebSocket debugging: localStorage.debug = 'socket.io-client:socket'
  3. Check server logs for detailed error information
  4. Use the included Postman collection for API testing

Performance Monitoring

  • Monitor WebSocket connection status
  • Track API response times
  • Watch for memory leaks in game components
  • Monitor token refresh frequency

Last updated: September 21, 2025
Backend Version: 1.0.0
API Version: v1