Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Feature 11: PVP System

Overview

Arena PVP with instant resolution (no wait timer), ELO-based matchmaking, stat normalization for fairness, 1v1 and 3v3 brackets, seasonal ratings with soft resets, and leaderboards. PVP matches use the same combat engine as PvE but with normalized stats.

Dependencies

  • Feature 06 (Combat Engine) — same simulation engine with PVP rules
  • Feature 05 (Skill Queue) — players use their configured queues

Technical Tasks

1. Database Migrations

  • Create migration 0010_create_pvp.sql:
    • pvp_ratings: character_id, bracket (‘1v1’/‘3v3’), rating (default 1000), season, wins, losses (PK: character_id + bracket + season)
    • pvp_matches: id, bracket, season, team_a (UUID[]), team_b (UUID[]), winner (‘a’/‘b’/‘draw’), match_log (JSONB), rating_changes (JSONB), resolved_at

2. PVP Stat Normalization (crates/game)

  • Create src/pvp.rs:
    • normalize_character(character: &CharacterSnapshot) -> PvpParticipant:
      • Normalize all base attributes to a baseline (e.g., 30 per attribute)
      • Keep weapon type and skill queue (strategy matters)
      • Keep gear enchantments/effects (effects matter, raw stats don’t)
      • Strip raw stat bonuses from gear
      • Set all participants to same HP pool
    • This ensures Patron speed bonus has zero PVP impact (arena resolves instantly)
    • Higher-level characters have more skills available (real advantage) but not stat advantage

3. ELO Matchmaking (crates/game)

  • Create src/pvp_matchmaking.rs:
    • calculate_elo_change(winner_rating: i32, loser_rating: i32) -> (i32, i32):
      • Standard ELO formula with K-factor 32
      • Winner gains, loser loses (different amounts based on rating gap)
    • is_viable_match(rating_a: i32, rating_b: i32, wait_seconds: u64) -> bool:
      • Start with ±150 rating window
      • Expand by 50 per 30 seconds of waiting
      • Max window: ±500

4. PVP Queue (Redis)

  • PVP queue stored in Redis sorted set: pvp:queue:{bracket} with score = rating
  • join_queue(redis, character_id, bracket, rating): ZADD
  • leave_queue(redis, character_id, bracket): ZREM
  • find_match(redis, bracket) -> Option<(CharId, CharId)>:
    • Scan sorted set for viable matches
    • Pop both matched players atomically

5. PVP Worker (crates/workers)

  • Create src/pvp_worker.rs:
    • Matchmaker (runs every 5 seconds via scheduler):
      • Call find_match for each bracket
      • For each match found: enqueue pvp-resolve job (immediate)
    • Match resolver (handles pvp-resolve queue):
      1. Load both characters’ snapshots
      2. Normalize stats
      3. Run combat simulation (same engine, PVP encounter rules)
      4. Determine winner
      5. Calculate ELO changes
      6. Write match result + rating updates to DB
      7. Create notifications for both players

6. PVP Seasons (crates/game)

  • Create src/pvp_seasons.rs:
    • Season duration: ~3 months
    • soft_reset_rating(current: i32) -> i32: compress toward 1000 (e.g., new = 1000 + (current - 1000) / 2)
    • Season rewards based on final rating tier:
      • Bronze (< 1200): title
      • Silver (1200-1499): title + portrait frame
      • Gold (1500-1799): title + portrait + materials
      • Diamond (1800+): title + portrait + exclusive seasonal item

7. PVP Routes (crates/api)

  • Create src/routes/pvp.rs:
    • POST /api/pvp/queue:
      • Body: { character_id, bracket }
      • Validate: character has active skill queue, has gear, not already in queue
      • Add to Redis PVP queue
      • Return { status: "queued" }
    • DELETE /api/pvp/queue:
      • Body: { character_id, bracket }
      • Remove from queue
    • GET /api/pvp/queue/status?character_id=:
      • Return: in_queue (bool), match_found (bool), match_id (if found)
      • Polled every 10 seconds by client
    • GET /api/pvp/history?character_id=:
      • Paginated match history: opponent, result, rating change, date
    • GET /api/pvp/leaderboard/:bracket:
      • Top 100 by rating from Redis sorted set
      • Include: rank, character name, rating, wins, losses
    • GET /api/pvp/ratings?character_id=:
      • Current ratings for all brackets + season

8. Client — PVP UI

  • Create routes/(game)/pvp/+page.svelte:
    • Bracket selector (1v1, 3v3)
    • Current rating, rank, W/L record
    • “Enter Queue” / “Leave Queue” button
    • Queue status indicator with polling (10s interval)
    • Match result modal when match completes
  • Create routes/(game)/pvp/history/+page.svelte:
    • Match history list with replay viewer (same as PvE run viewer)
  • Create routes/(game)/pvp/leaderboard/+page.svelte:
    • Top 100 table per bracket
    • Highlight current player’s rank

Tests

Unit Tests

  • normalize_character: all attributes set to baseline
  • normalize_character: gear effects preserved, raw stats stripped
  • normalize_character: HP pool equalized
  • calculate_elo_change: higher rated player gains less from beating lower rated
  • calculate_elo_change: lower rated player gains more from upset
  • calculate_elo_change: sum of changes is approximately zero
  • is_viable_match: accepts ±150 at 0 seconds wait
  • is_viable_match: rejects ±200 at 0 seconds, accepts at 30 seconds
  • soft_reset_rating: compresses 1500 → 1250, 500 → 750
  • PVP combat simulation: deterministic with same seed
  • PVP combat simulation: stat normalization means level 10 vs level 50 is fair on raw stats

Integration Tests

  • POST /api/pvp/queue adds to Redis sorted set
  • DELETE /api/pvp/queue removes from Redis
  • POST /api/pvp/queue rejects without active skill queue
  • Matchmaker finds viable match and enqueues resolution
  • PVP worker resolves match, writes result, updates ratings
  • GET /api/pvp/queue/status shows match_found after resolution
  • GET /api/pvp/history returns match results
  • GET /api/pvp/leaderboard/1v1 returns top 100 sorted by rating
  • GET /api/pvp/ratings returns current season ratings
  • Concurrent queue joins: two players with close ratings → matched within 10 seconds